談一談元件化

記得要微笑發表於2022-02-28

前言

今天前端生態裡面,ReactAngularVue三分天下。雖然這三個框架的定位各有不同,但是它們有一個核心的共同點,那就是提供了元件化的能力。W3C也有Web Component的相關草案,也是為了提供元件化能力。今天我們就來聊聊元件化是什麼,以及它為什麼這麼重要。

正文

其實元件化思想是一種前端技術非常自然的延伸,如果你使用過HTML,相信你一定有過“我要是能定義一個標籤就好了”這樣的想法。HTML雖然提供了一百多個標籤,但是它們都只能實現一些非常初級的功能。

HTML本身的目標,是標準化的語義,既然是標準化,跟自定義標籤名就有一定的衝突。所以從前端最早出現的2005年,到現在2022年,我們一直沒有等到自定義標籤這個功能,至今仍然是Draft狀態。

但是,前端元件化的需求一直都存在,歷史長流中工程師們提出過很多元件化的解決方案。

ExtJS

Ext JS是一個流行的JavaScript框架,它為使用跨瀏覽器功能構建Web應用程式提供了豐富的UI。我們來看看它的元件定義:

MainPanel = function() {
  this.preview = new Ext.Panel({
    id: "preview",
    region: "south"
    // ...
  });
  MainPanel.superclass.constructor.call(this, {
    id: "main-tabs",
    activeTab: 0,
    region: "center"
    // ...
  });

  this.gsm = this.grid.getSelectionModel();

  this.gsm.on(
    "rowselect", function(sm, index, record) {
      // ...
    }, this, { buffer: 250 }
  );

  this.grid.store.on("beforeload", this.preview.clear, this.preview);
  this.grid.store.on("load", this.gsm.selectFirstRow, this.gsm);

  this.grid.on("rowdbclick", this.openTab, this);
};

Ext.extend(MainPanel, Ext.TabPanel, {
  loadFeed: function(feed) {
    // ...
  },
  // ...
  movePreview: function(m, pressed) {
    // ...
  }
});

你可以看到ExtJS將元件設計成一個函式容器,接受元件配置引數optionsappend到指定DOM上。這是一個完全使用JS來實現元件的體系,它定義了嚴格的繼承關係,以及初始化、渲染、銷燬的生命週期,這樣的方案很好地支撐了ExtJS的前端架構。

https://www.w3cschool.cn/extj...

HTML Component

搞前端時間比較長的同學都會知道一個東西,那就是HTCHTML Components),這個東西名字很現在流行的Web Components很像,但卻是不同的兩個東西,它們的思路有很多相似點,但是前者已是昨日黃花,後者方興未艾,是什麼造成了它們的這種差距呢?

因為主流瀏覽器裡面只有IE支援過HTC,所以很多人潛意識都認為它不標準,但其實它也是有標準文件的,而且到現在還有連結,注意它的時間!

http://www.w3.org/TR/NOTE-HTM...

MSDN onlineHTC的定義僅如下幾句:

HTML Components (HTCs) provide a mechanism to implement components in script as Dynamic HTML (DHTML) behaviors. Saved with an .htc extension, an HTC is an HTML file that contains script and a set of HTC-specific elements that define the component.
(HTC是由HTML標記、特殊標記和指令碼組成的定義了DHTML特性的元件.)

作為元件,它也有屬性、方法、事件,下面簡要說明其定義方式:

  • <PUBLIC:COMPONENT></PUBLIC:COMPONENT>:定義HTC,這個標籤是其他定義的父元素。
  • <PUBLIC:PROPERTY NAME=”pName” GET=”getMethod” PUT=”putMethod” />: 定義HTC的屬性,裡面三個定義分別代表屬性名、讀取屬性、設定屬性時HTC所呼叫的方法。
  • <PUBLIC:METHOD NAME=”mName” />:定義HTC的方法,NAME定義了方法名。
  • <PUBLIC:EVENT NAME=”eName” ID=”eId” />:定義了HTC的事件,NAME定義了事件名,ID是個可選屬性,在HTC中唯一標識這個事件。
  • <PUBLID:ATTACH EVENT=”sEvent” ONEVENT=”doEvent” />:定義了瀏覽器傳給HTC事件的相應方法,其中EVENT是瀏覽器傳入的事件,ONEVENT是處理事件的方法。

我們來看看它主要能做什麼呢?

它可以以兩種方式被引入到HTML頁面中,一種是作為“行為”被附加到元素,使用CSS引入,一種是作為“元件”,擴充套件HTML的標籤體系

行為為指令碼封裝和程式碼重用提供了一種手段

通過行為,可以輕鬆地將互動效果新增為可跨多個頁面重用的封裝元件。例如,考慮在 Internet Explorer 4.0 中實現onmouseover highlight的效果,通過使用 CSS 規則,以及動態更改樣式的能力,很容易在頁面上實現這種效果。在 Internet Explorer 4.0 中,實現在列表元素li上實現 onmouseover 高亮可以使用onmouseoveronmouseout事件動態更改li元素樣式:

<HEAD>
<STYLE>
.HILITE
{ color:red;letter-spacing:2; }
</STYLE>
</HEAD>

<BODY>
<UL>
<LI onmouseover="this.className='HILITE'"
    onmouseout ="this.className=''">HTML Authoring</LI>
</UL>
</BODY>

Internet Explorer 5 開始,可以通過 DHTML 行為來實現此效果。當將DHTML行為應用於li元素時,此行為擴充套件了列表項的預設行為,在使用者將滑鼠移到其上時更改其顏色。

下面的示例以 HTML 元件 (HTC) 檔案的形式實現一個行為,該檔案包含在hilite.htc檔案中,以實現滑鼠懸停高亮效果。使用 CSS 行為屬性將行為應用到元素li 上。上述程式碼在 Internet Explorer 5 及更高版本中可能如下所示:

// hilite.htc
<HTML xmlns:PUBLIC="urn:HTMLComponent">
// <ATTACH> 元素定義了瀏覽器傳給HTC事件的相應方法,其中EVENT是瀏覽器傳入的事件,ONEVENT是處理事件的方法
<PUBLIC:ATTACH EVENT="onmouseover" ONEVENT="Hilite()" />
<PUBLIC:ATTACH EVENT="onmouseout"  ONEVENT="Restore()" />
<SCRIPT LANGUAGE="JScript">
var normalColor;

function Hilite()
{
   if (event.srcElement == element)
   {
     normalColor = style.color;
     runtimeStyle.color  = "red";
     runtimeStyle.cursor = "hand";
   }
}

function Restore()
{
   if (event.srcElement == element)
   {
      runtimeStyle.color  = normalColor;
      runtimeStyle.cursor = "";
   }
}
</SCRIPT>

通過CSS behavior屬性將DHTML行為附加到頁面上的元素

<HEAD>
<STYLE>
   LI {behavior:url(hilite.htc)}
</STYLE>
</HEAD>

<BODY>
<UL>
  <LI>HTML Authoring</LI>
</UL>
</BODY>

HTC自定義標記

我們經常看到某些網頁上有這樣的效果:使用者點選一個按鈕,文字顯示,再次點選這個按鈕,文字消失,但瀏覽器並不重新整理。下面我就用HTC來實現這個簡單效果。程式設計思路是這樣的:用HTC模擬一個開關,它有”on””off”兩種狀態(可讀/寫屬性status);使用者可以設定這兩種狀態下開關所顯示的文字(設定屬性 turnOffTextturnOnText);使用者點選開關時,開關狀態被反置,並觸發一個事件(onStatusChanged)通知使用者,使用者可以自己寫程式碼來響應這個事件;該HTC還定義了一個方法(reverseStatus),用來反置開關的狀態。下面是這個HTC的程式碼:

<!—switch.htc定義 -->  
<PUBLIC:COMPONENT TAGNAME="Switch">  
    <!--屬性定義-->  
    <PUBLIC:PROPERTY NAME="turnOnText" PUT="setTurnOnText" VALUE="Turn on" />  
    <PUBLIC:PROPERTY NAME="turnOffText" PUT="setTurnOffText" VALUE="Turn off" />  
    <PUBLIC:PROPERTY NAME="status" GET="getStatus" PUT="setStatus" VALUE="on" />  
  
    <!--定義事件-->  
    <PUBLIC:EVENT NAME="onStatusChanged" ID="changedEvent" />  
  
    <!--定義方法-->  
    <PUBLIC:METHOD NAME="reverseStatus" />  
  
    <!--關聯客戶端事件-->  
    <PUBLIC:ATTACH EVENT="oncontentready" ONEVENT="initialize()"/>  
    <PUBLIC:ATTACH EVENT="onclick" ONEVENT="expandCollapse()"/>  
  
</PUBLIC:COMPONENT>  
  
<!-- htc指令碼 -->  
<script language="javascript">  
    var sTurnOnText;    //關閉狀態所顯示的文字  
    var sTurnOffText;   //開啟狀態所顯示的文字  
    var sStatus;    //開關狀態  
    var innerHTML   //使用開關時包含在開關中的HTML     
  
    //設定開關關閉狀態所顯示的文字  
    function setTurnOnText(value)  
    {  
        sTurnOnText = value;  
    }   
  
    //設定開關開啟狀態所顯示的文字  
    function setTurnOffText(value)  
    {  
        sTurnOffText = value;  
    }     
  
    //設定開關狀態  
    function setStatus(value)  
    {  
        sStatus = value;  
    }   
  
     //讀取開關狀態  
    function getStatus()  
    {  
        return sStatus;  
    }     
  
    //反向開關的狀態  
    function reverseStatus()  
    {  
        sStatus = (sStatus == "on") ? "off" : "on";  
    }  
  
    //獲取htc控制介面html文字  
    function getTitle()  
    {  
        var text = (sStatus == "on") ? sTurnOffText : sTurnOnText;  
        text = "<div id='innerDiv'>" + text + "</div>";  
        return text;  
  
    }  
  
    //htc初始化程式碼  
    function initialize()  
    {  
        //back up innerHTML  
        innerHTML = element.innerHTML;  
        element.innerHTML = (sStatus == "on") ? getTitle() + innerHTML : getTitle();  
    }   
  
    //響應使用者滑鼠事件的方法  
    function expandCollapse()  
    {  
         reverseStatus();  
         //觸發事件  
         var oEvent = createEventObject();  
         changedEvent.fire(oEvent);    
         var srcElem = element.document.parentWindow.event.srcElement;  
         if(srcElem.id == "innerDiv")  
         {  
              element.innerHTML = (sStatus == "on") ? getTitle() + innerHTML : getTitle();  
         }  
    }  
</script>  

html頁面引入自定義標記

<!--learnhtc.html-->  
<html xmlns:frogone><!--定義一個新的名稱空間-->  
<head>  
    <!--告訴瀏覽器名稱空間是由哪個HTC實現的-->  
    <?IMPORT namespace="frogone" implementation="switch.htc">  
</head>  
<body>  
   <!--設定開關的各個屬性及內部包含的內容-->  
   <frogone:Switch id="mySwitch"  
                    TurnOffText="off"  
                    TurnOnText="on"  
                    status="off"  
                    onStatusChanged="confirmChange()">  
        <div id="dBody">文字內容...... </div>  
    </frogone:Switch>  
</body>  
<script language="javascript">  
    //相應開關事件  
    function confirmChange()  
    {  
        if(!confirm("是否改變開關狀態?"))  
            mySwitch.reverseStatus();
    }  
</script>  
</html>

這項技術提供了事件繫結和屬性、方法定義,以及一些生命週期相關的事件,應該說已經是一個比較完整的元件化方案了。但是我們可以看到後來的結果,它沒有能夠進入標準,默默地消失了。用我們今天的角度來看,它可以說是生不逢時。

如何定義一個元件

ExtJS基於物件導向的思想,將元件設計成函式容器,擁有嚴格的繼承關係和元件生命週期鉤子。HTC利用IE瀏覽器內建的一種指令碼封裝機制,將行為從文件結構中分離,通過類似樣式或者自定義標識的方式為HTML頁面引入高階的自定義行為(behavior)。從歷史上元件化的嘗試來看,我們應該如何來定義一個元件呢?

首先應該清楚元件化的設想為了解決什麼問題?不言而喻,元件化最直接的目的就是複用,提高開發效率,作為一個元件應該滿足下面幾個條件:

  • 封裝:元件遮蔽了內部的細節,元件的使用者可以只關心元件的屬性、事件和方法。
  • 解耦:元件本身隔離了變化,元件開發者和業務開發者可以根據元件的約定各自獨立開發和測試。
  • 複用:元件將會作為一種複用單元,被用在多處。
  • 抽象:元件通過屬性和事件、方法等基礎設施,提供了一種描述UI的統一模式,降低了使用者學習的心智成本。

接下來我們深入具體的技術細節,看看元件化的基本思路。首先,最基礎的語義化標籤就能看作成一個個元件,通過DOM API可以直接掛載到對應的元素上:

var element = document.createElement('div')
document.getElementById('container').appendChild(element)

但是實際上我們的元件不可能這麼簡單,涵蓋場景比較多的元件都比較複雜,工程師想到將元件定義為原生JS中的Function或者Class容器,形如:

function MyComponent(){
    this.prop1;
    this.method1;
    ……
}

不過,要想掛載又成了難題,普通的JS物件沒法被用於appendChild,所以前端工程師就有了兩種思路,第一種是反過來,設計一個appendTo方法,讓元件把自己掛到DOM樹上去。

function MyComponent(){
    this.root = document.createElement("div");
    this.appendTo = function(node){
        node.appendChild(this._root)
    }
}

第二種比較有意思,是讓元件直接返回一個DOM元素,把方法和自定義屬性掛到這個元素上:

function MyComponent(){
    var _root = document.createElement("div");
    root.prop1 // = ...
    root.method1 = function(){
    /*....*/
    }
    return root;
}

document.getElementById("container").appendChild(new MyComponent());

下面我們根據上面思想來設計一個輪播元件,能夠自動播放圖片

Picture1.gif

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    .carousel, .carousel > img {
      width: 500px;
      height: 300px;
    }

    .carousel {
      display: flex;
      overflow: hidden;
    }

    .carousel > img {
      transition: transform ease 0.5s;
    }
  </style>
</head>
<body>
  <script>
    let d = [
      {
          img: "https://static001.geekbang.org/resource/image/bb/21/bb38fb7c1073eaee1755f81131f11d21.jpg",
          url: "https://time.geekbang.org",
          title: "藍貓"
      },
      {
          img: "https://static001.geekbang.org/resource/image/1b/21/1b809d9a2bdf3ecc481322d7c9223c21.jpg",
          url: "https://time.geekbang.org",
          title: "橘貓"
      },
      {
          img: "https://static001.geekbang.org/resource/image/b6/4f/b6d65b2f12646a9fd6b8cb2b020d754f.jpg",
          url: "https://time.geekbang.org",
          title: "橘貓加白"
      },
      {
          img: "https://static001.geekbang.org/resource/image/73/e4/730ea9c393def7975deceb48b3eb6fe4.jpg",
          url: "https://time.geekbang.org",
          title: "貓"
      }
    ];

    class Carousel {
      constructor(data) {
        this._root = document.createElement('div');
        this._root.classList = ['carousel']
        this.children = [];
        for (const d of data) {
          const img = document.createElement('img');
          img.src = d.img;
          this._root.appendChild(img);
          this.children.push(img);
        }

        let i = 0;
        let current = i
        setInterval(() => {
          for (const child of this.children) {
            child.style.zIndex = '0';
          }
          // 計算下一張圖片的下標
          let next = (i + 1) % this.children.length;

          const currentElement = this.children[current];
          const nextElement = this.children[next];

          // 下一張圖片的zIndex應該大於當前圖片的zIndex
          currentElement.style.zIndex = '1';
          nextElement.style.zIndex = '2';
          // 禁止新增的動畫過渡樣式
          currentElement.style.transition = 'none';
          nextElement.style.transition = 'none';
          console.log('current', current, next)
          // 每次初始化當前圖片和下一張圖片的位置
          currentElement.style.transform = `translate3d(${-100 * current}%, 0 , 0)`;
          nextElement.style.transform = `translate3d(${100 - 100 * next}%, 0 , 0)`;

          setTimeout(() => {
            // 啟動新增的動畫過渡樣式
            currentElement.style.transition = '';
            nextElement.style.transition = '';
            // 當前圖片退出,下一張圖片進來
            currentElement.style.transform = `translate3d(${-100 -100 * current}% 0 , 0)`;
            nextElement.style.transform = `translate3d(${-100 * next}%, 0 , 0)`;
          }, 1000 / 60);

          current = next;
          i++;
        }, 3000);

        // 追加 
        this.appendTo = function(node){
          node.appendChild(this._root)
        }
      }
    }

    new Carousel(d).appendTo(document.body);
  </script>
</body>
</html>

效果:
amimation.gif

未完待續!!!

參考:

HTC(HTML Component) 入門

HTML Component

相關文章