React 設計模式 - 複合元件

一般來說在進行正式的專案開發,製作可以重複被使用的元件時,常常也必須考慮到元件的彈性、與可維護性。因為我們不知道在未來,這個元件會因為產品的需求而產生怎麼樣的調整,所以最低程度地保持元件的可擴充性就可以讓開發者在這個時候比較輕鬆的以最小限度的影響來達成想要的修改。

複合元件( Compound Component )就是一種可以同時提高可維護性跟彈性的元件設計方法,或稱為設計模式( Design Patern ) 。設計模式一詞源自更早期的軟體界發展歷史,這個詞可以用來代稱那些應付經常出現問題的解決辦法。亦即設計模式是經年累月透過所有軟體界的開發者不斷地遇到相同或類似問題之後,慢慢整理出來的方法。有一些設計模式是所有語言都可以通用的,例如你可能會聽過的「 工廠模式 」、「 觀察者模式」。而有一些模式是針對特定情境的問題所設計的,我們今天要談的複合元件就是前端元件開發的設計模式之一。

需要的先備知識

這篇文章主要會講解如何設計一個複合元件,在繼續往下閱讀之前,你可能要確保自己了解 React children 與 context 的相關概念,才能夠比較輕鬆的閱讀這篇文章所提及的內容,在這邊附上幾篇可以參考的資源:

複合元件是什麼

複合元件,或稱合成元件( Compound Component )可以從詞面上看出它大致的意思,就是指數個聚集在一起,並有某些相互關係的元件。透過複合元件,可以讓兩個或兩個以上相關聯的元件用一些不明顯的方式來共用狀態,這是什麼意思,而複合元件到底又解決了什麼樣的問題呢?

元件常見的多層結構問題

舉我們常見的下拉選單為例,如果我們現在想要製作自己的選單元件,最直觀的製作方法會是直接創造一個元件,然後把所有相關的資料傳入:

import { useState } from "react";

function Option ({name, value, onClick}) {
    return 
        <option
          className="option"
          value={value}
          onClick={onClick}
        >
          {name}
        </option>
}

export default function Select({ options }) {
  const [selectedValue, setSelectedValue] = useState(options[0]);
  const onClickOption = (option) => () => setSelectedValue(option);
  return (
    <div className="root">
      <div className="selectedValue"> {selectedValue.name} </div>
      {options.map((option) => (
              <Option name={option.name} value={option.value} onClick={onClickOption(option)}/>  
      ))}
    </div>
  );
}

上面的程式碼範例只是一個示意,所以並沒有考慮到 CSS 的樣式,這邊會以狀態的傳遞方式為重點。所以以這個下拉選單的例子來說,我們會把整個選單的名字跟對應的數值,直接傳入這個 <Select/>  元件裡面。

const optionList = [
    { name: "optionA", value: "valueA" },
    { name: "optionB", value: "valueB" }
  ];

...

<Select options={optionList} />

但是這麼一來除了沒辦法直接存取到 <Option/> 元件之外,你也可以發現這份相關的選單資料其實是從最上層傳入 <Select/> 元件之後,再次被傳入 <Option/><Option> 這個元件才有辦法拿到需要用來顯示的選項內容與對應數值。

一般我們不會樂見這種資料被層層傳遞的使用方式,你可能覺得兩層可能還好,不會太難看出它們的關係,但在實際開發時總是無法永遠確保不會有需要第三層的情況出現,因此雖然難免還是有些不得已必須這麼使用的情況,大多時候對開發者來說這種情況當然是越少越好。你可以想像使用這個用法製作的元件,在當所必須傳入的屬性隨著時間和需求的改變而變得太多時,它會變得越來越難看清楚每個屬性所對應的內容,和與之相關的流程邏輯。

對上面的 <Select/> 元件來說,也許一個比較好的設計方式最好還是能夠跟原生的 select 與 option 標籤具有同樣的使用方式:

<select>
  <option value="value1">optionA</option>
  <option value="value2">optionB</option>
</select>

當我們把 <Select/> 元件用某種方式拆分成兩個個別獨立,且相關連的元件之後,就能夠享有一個好處是能夠把元件的樣式內容分開來管理,也就是說我們在設計元件時不需要把所有的程式碼塞在一起。而除了讓內容分離之外,使用複合元件的另外一個很大的好處是能夠讓狀態在這兩個相關連的元件之間共享,這麼一來也可以減少把狀態「傳到上層在傳到下層」的情況發生。

複合元件的好處

綜上所述,由於複合元件原本就是為了讓元件之間的相依,並提高使用彈性的方法。所以他所帶來的好處也不難理解:

  • 讓開發者能夠更容易且隨心所欲控制元件,例如:元件的排列順序。
  • 讓相關元件扁平化,可以使用在同一層而不用全部包在一個地方。
  • 元件內容分離,讓元件更容易管理,就像我們即將要做的:把上面原本的 <Select/> ,拆出 <Option/> ,分離成兩個元件。

<Select/> 為例子來設計複合元件

下拉選單是前端一個很常見的需求,尤其我們因為樣式的關係,無法使用原生的 Html 元素,所以常常需要客製化的製作。這邊我們就來看看怎麼利用 React 來設計一個跟前端原生的 <select> 元素使用方式較為相近的元件吧!在這個例子裡面,我會以 Class Component 為基礎來進行設計。

元件的條件要求:

  • <Select><Option> 兩個元件必須要能夠分開使用而不是全部放在同一個元件中,像是這樣:
      <Select>
        <Option value="value 1" >
          option 1
        </Option>
        <Option value="value 2" >
          option 2
        </Option>
        <Option value="value 3" >
          option 3
        </Option>
      </Select>
  • <Select/> 必須能夠根據其子元素的所有 <Option/> 元件內容以及排列順序來顯示下拉清單
  • 元件的製作結果如下:

先思考元件結構

通常複合元件中,會有一個作為父層元件的主元件,而由其他的元件作為子元件。不過在 <Select/><Option/> 的例子中則比較單純,只有父層的 <Select/> 與子層的 <Option/>。以這個架構為前提之下,我們可以繼續往下想想另外幾個問題,那就是:

  • 作為父層元件的 <Select/> 是怎麼知道在它下層的 <Option/> 的內容的?
  • <Select/> 又是怎麼比對出當下使用者所選到的選項的?

只要能夠解答這兩個問題,我們基本上就掌握了實作這個 <Select/> 複合元件的關鍵。

首先,第一個問題的思考方向是一個很常見的 children 屬性,我們只要透過這個屬性就能夠取得「被放入」元件的內容,也就是所有的 <Option/> 元件。而關於 <Select/> 是怎麼知道目前所選的數值的?可想而知的是在 <Select/> 內一定會有一個狀態負責儲存目前被選中的值。

另外我們再每個 <Option/> 被選中時也必須進行更新這個被選中數值的動作,所以我們也需要在 Option 元件上面掛上對應的 onClick 事件,這麼一來當 <Option> 被點擊時,<Select/> 才有辦法知道 <Option/> 選中的數值是哪一個。關於這一點我們可以透過 React 的相關 API : Children.map 以及 cloneElement 來達成。Children.map 負責巡訪每個 <Option/> 子元件 , cloneElement 則複製一個新的 Option 並讓我們可以在這個時候再次傳入額外的屬性。

// ... 在 Select 元件內
// 選項表單
 <div className="optionList">
              {Children.map(children, (child) =>
                React.cloneElement(child, {
                  ...child.props,
                  onClick: this.onOptionClick(child),
                                 })
              )}
</div>

藉由上述的程式碼片段可以看出我把個別的子元件內容傳給了 onClick 事件,接下來在事件裡面只要知道要儲存什麼數值藉以比對選中的選項,並拿到元件上對應的屬性值,就能夠透過這個數值進行比對。

// ... 在 Select 元件內
// Select 元件的狀態 
...
state = {
    selectedOptionKey: "",
    selectedOptionValue: "",
    selectedOption: null,
    openOptionList: false
};

...

// Option 上的點擊事件
onOptionClick = (child) => () => {
    const {
      props: { value, optionKey }
    } = child;
    this.setSelectedOptionKey(optionKey);
    this.setSelectedOptionValue(value);
    this.setState({ selectedOption: child });
    this.toggleOptionList();
};

在點擊事件內雖然我做了許多件事情,不過可以看到我從 child 的 props (也就是 <Option/> 上的屬性內容)取出兩個屬性值,分別為 value 以及 optionKey,其中 optionKey 是每個 <Option/> 上都會有,用來區別選項且應該要不重複的值,之後我們就可以拿這個值來決定目前所選中的選項是哪一個。

比對的方式很單純,既然我們可以拿到 <Option/> 上的屬性,我們就能夠拿出來跟儲存在 <Select/> 代表被選中的 <Option/> 的 selectedOptionKey 這個狀態做比對,如下:

// ... 在 Select 元件內
// 選項表單
 <div className="optionList">
              {Children.map(children, (child) =>
                React.cloneElement(child, {
                  ...child.props,
                  onClick: this.onOptionClick(child),
                  isSelected:
                    child.props.optionKey === this.state.selectedOptionKey
                                 })
              )}
</div>

我們直接利用 isSelected 以布林值的形式傳給下層的 <Option/> ,這麼一來 <Option/> 就能夠透過這個數值決定要顯示什麼樣的內容,或是樣式,我們來看看 <Option/> 的內容。

// Option 元件

function ClassOption({ children, isSelected, onClick }) {
  return (
    <div
      onClick={onClick}
      className={`option ${isSelected ? "isSelected" : ""}`}
    >
      {children}
    </div>
  );
}

由於這個例子中 <Option/> 需要顯示的東西和需要判斷的邏輯比較單純,所以元件內容也比較單純,但可以看到我們拿到從 <Select/> 元件裡面傳入的兩個屬性來做一些顯示和判斷,這兩個屬性都不是在使用元件時傳入的,而是從 Select 元件來,所以如果不了解原理的話,就沒辦法直接看出來處。

利用 Class Compoennt 靜態屬性

通常複合元件在設計時會把複合元件中的子元件掛在父元件上,而因為在本篇文章所舉的例子裡面,剛好用到的是 Class Component ,所以可以利用 Class 中靜態屬性的概念,讓兩個看似分離的元件,更明確的產生關聯,所以我們在 <Select/> 元件內會多做一件事情:

// Select 元件內
static Option = ClassOption;

這麼一來開發者就可以用 <Select.Option> 來表示 <Option/> 元件,也可以讓使用的開發者馬上理解兩者是有關聯的,而這也是你在各大React UI 元件框架裡面常常會看到的用法。你可能會問:為什麼可以在標籤內存取 Select 底下的屬性 Option ?

這就要回歸到語法的本質了,要了解這件事你比須先了解一件事就是,這邊所使用的<..> 角括號並不是原生的 Html 標籤,而是所謂用來簡化 React 寫法的 JSX 語法,它所代替的程式碼片段其實是最早 React 內的 React.createElement方法,參考下面的例子:

class Hello extends React.Component {
  render() {
    return React.createElement('div', null, `Hello ${this.props.text}`);
  }
}

以上面這個元件為例,下面兩種用法所產生的結果都是一樣的:

React.createElement(Hello, {text: 'World'}, null),
或是
<Hello text="World"/>

JSX 這種看起來像是標籤的用法其實就是簡化 React 的寫法,既然原本 React 也是透過把 JavaScript 的元件類別傳入 createElement 這個方法中,在這個前提之下它所接收的類別當然與一般 JavaScript 的類別並無二致囉。這個用法對不了解的人看起來可能會眼花撩亂,但其實其中的原理就是這麼單純而已。

善用 Context API

雖然在這個例子裡面我們沒有使用到,不過若是想要設計的複合元件結構比較複雜,有多層元件結構的話,可以利用 React 提供的 Context API 來進行跨多層元件的數值內容傳遞。

// Select 元件
<SelectContext.Provider value={{ [ 想要傳遞的數值內容 ]  }}>
//  ... 元件內容
<SelectContext.Provider>

最終完成的結果

在上面的內容之中我只解說幾個最關鍵的部分,至於沒有講到的其他細節,就請讀者自己思考看看囉,在這邊附上完整的程式碼範例。

動手試試看

本篇文章的是以 Class Component 為例子來進行設計這個複合元件,不過在撰寫這篇文章的此刻,Functional Component 是實際開發時比較主流的元件類型,其實利用 Functional Component 照理說也能夠設計出使用方式與這個例子完全一樣的複合元件,讀者們在了解複合元件的概念後,也可以自己嘗試使用 Functional Component 來實做看看自己的複合元件,那麼,就先說到這囉!

每個衝擊都是一次學習的機會 - 近期的一點心得

好久不見。各位最近過得如何呢 ? 是不是又往自己的目標前進了一點 ? 一段時間沒有寫文章了,由於轉換工作環境的緣故,自從三月以來,這是第一次可以空出時間好好寫下一些東西。

我目前在 Snapask 任職,這是我第一次到比較有規模的團隊裡面,有很多專案流程的細節是以前沒有接觸過的,所以這幾個月來相對於技術學習上。我在思考方式以及工作方法等方面比較有心得,所以這篇文章不會有技術內容,而是會跟大家分享我近期的收穫,或許能夠給未來也跟我面臨相似問題的人一個參考。

目前我的公司剛好有一個 Web Team 的職缺,JD 在這,如果有興趣的人歡迎找我聊聊,或許可以幫你評估一下狀況然後幫你 Refer ,加快面試流程,你可以透過以下方式找到我:

個人過往技術狀況

有一些經驗的 Junior,靠自學誤打誤撞進入前端領域(剛滿兩年),前面經歷的都算是較小型的新創,團隊大多都不超過十人。當時工作較為偏向單打獨鬥,內容較有彈性,技術工具及解決方法策略的選擇上相對自由,可以自行決定然後直接執行,但時程規劃比較沒有組織跟流程。

參與新的團隊

因為以前合作經驗較少,所以第一次進到團隊,有很多新的工作方法以前沒有參與跟執行過,例如大家認為很常見的 Code Review 、例如跟主管們 1 on 1 、還有 scrum 中的會議流程…,其實有許多細節。

不過因為實際會遇到的工作細節每間公司一定都會不同,所以在接下來的內容我不會一一詳細的解說,我會以個人成長的角度來分享在這個新環境中學到、並為我在職涯發展上帶來不同見解的觀點。

從就職開始說起,那些剛到職的 Junior 們可能聽說過很多次,不過我可以再用我的個人經驗告訴你,永遠不要「只」以薪水來決定工作的選擇(當然該拿的還是要拿,不過如果你真的很想體驗一整年都沒有成長的倦怠感,那麼你應該試試)。

進入新環境的第一要務

我想對大部分的 Junior 來說,進入公司後最重要的一件事就是優先找到能夠讓你學習並成長的對象 ( Mentor / Role Model ),這長期來講會是一個重要的外在因素,不用擔心找不到,每個人都有值得學習的部分,但如果你觀察幾個月之後還是沒有答案,那麼你可能到錯地方了。

適應之後

工作一段時間差不多適應新的環境以後,可能會有兩種情況,一種是工作模式跟以前所習慣的差不多,一種是跟我比較像也就是公司的工作流程跟以往使用的有比較大的差距。不管是在哪一種情況底下,在這個時期都應該要著重於一件事情:

找到你想做的事 / 想解決的問題

不要把自己當勞工來看,找到一個你想達成的長期目標,通常這個目標不會是被分派的任務之一,因為你會有自己的目標,公司也會有自己的步伐要前進,想辦法找到這兩者間的平衡點。這會回到一個問題是你當初選擇這間公司的原因、或是說你想在這間公司想得獲得的經驗、講官腔一點也能說是未來展望。相較於上面提到的外在因素,這則會是長期的內在動機,也是遇到阻礙時能夠支撐你的重要原因。

對那些適應良好的人這件事可能會自然地發生。即使是對不太能適應或遇到麻煩的人,若你有確保做好第一點提到的確保工作環境有學習對象,那麼你應該能夠得到一些引導而在設法解決後再度回來思考這個問題。

我獲得的幾個觀點

1. 看見每個人價值觀的不同

在中小型團隊裡面會遇到許多不同的人,幾個月的合作跟交流下來我可以明顯感受到每個人都有每個人的做事方法跟步調,這是一個很棒的地方,因為每個人都有能夠學習的地方,試試看跟他們交流你的想法,常常能夠讓你從不同角度重新看帶事情。

2. 先想清楚,再動刀

這一點,講老套一點就是三思而後行,在執行某個任務之前應該先在腦中演練過要執行的細節,這個 pattern 不只是工程師的基本能力,生活中很多地方都能夠應用。也避免邊做邊想讓自己遭遇一團混亂的處境。
以我的經驗來說,在以往的工作經驗裡面我都可以快速執行想做的方法然後遇到問題再快速設法解決,但在注重工作流程的公司這麼做就很容易遇到狀況。

3. 不斷尋找工作流程上能夠改善的地方

這不管對哪一個階段的工作者都很重要,但我相信對初出茅廬的 Junior 工程師來說尤然。每個人在執行上都會有盲點,對較為資深的工程師來說這些盲點可能不會是個大問題,但對資淺工程師來說的話,一旦某個環節碰到問題,通常就會讓他們立刻明顯的感受到阻礙。

4. 使用新工具:蕃茄鐘

這一段呼應上一點,我是一個非常容易分心的人,因為我對許多身邊發生的事感到好奇,我相信這是我的優點。不過這個特點對於完成任務來說反而是一個問題。我知道我需要建立一個能夠讓自己專心的工作模式,試了很多方式後決定開始試試看使用蕃茄鐘,沒想到一試成主顧。

基本上蕃茄鐘就是讓自己專心時間後,再放鬆一小段時間,然後持續進行這個循環。對我來說感受比較明顯的地方有:

  • 明確感到時間的流逝
    也就是增強對時間的感知,以往因為較長時間的連續執行工作,有時候容易造成正在做的事情已經偏離最一開始想要達成的目標,但自己還沒有察覺,在番茄鐘的工作法底下比較能夠知道目前正在做的某件事情已經花掉多少時間。
  • 比較能夠把一件大任務切成許多小部分
    因為蕃茄鐘的這種模式,所以在使用時自然必須學著切分任務,再開始進行之前先想好這段時間要做什麼事情,並在結束後來看看自己的成效。
  • 適度讓大腦休息
    長時間的連續工作真的比較容易遇到盲點,例如有時候遇到某個問題解不掉,然後又執著在錯誤的程式碼環節,就容易卡住很長一段時間,但往往休息一段時間後再回來,不就後就能找到癥結點。

勾勒自己的未來

以上就是我近期的心得,寫在最後面,有一件應該要常常被思考的事情是去設想自己在近幾年之後在工作領域裡的角色定位,你想要有能力做到什麼樣的事情?解決什麼樣的問題?這些都是最基本卻又很容易被忽略的事情。

畢竟在這個工程師這個行業裡頭,技術是學不完的,但你的時間卻非常有限。不知道這些經驗能夠幫助你到什麼地步,不過還是希望你能夠有所收穫,不用免強自己做個鐵人、也不要甘於當個水手,做個靈活的海盜,適當利用環境、武器跟手邊的資源,該守就守,該進攻進攻,容許自己能夠時時刻刻調整航向跟目的地。如果將來我們能夠在航道上相遇,那麼希望我能夠ㄧ如往常地帶給你一些新奇的東西與驚喜。就寫到這,下次又等到我有空時再見了!

寫給職場工作者:工作環境是否會影響一個人的思考方式?

自從我進入大學階段之後,就一直有人不斷對我說要快一點決定未來人生的方向,每次被問到這個問題我都不是很能理解,這個在前面將近一半的學習生涯把學生教成考試機器的教育體系,為何又理直氣壯地期望這些學生在短短幾年內如獲天啓般突然知道自己的人生規劃?

綜上所述,我目前是一位軟體工程師,今年是我工作的第三年,我依舊在思考人生目標,但值得慶幸的是我正在做自己喜歡的工作(而且我學很快)。雖然對未來職涯方向還沒有很清楚的藍圖,但是對於這幾年來在職場體驗到所發生的人事物依舊有一些心得,所以今天想來跟各位聊聊(個人角度下)工作本身對工作者的長期影響。

職場制約論

我相信大家應該都了解「環境很重要」這件事,以一個在台灣常見的上班族來說,光是工作的時間每天就有八小時(以上),仔細想想就佔了一個社會人士的人生將近 1/3 的時間,我們往往沒有意識到,這段時間裡面我們所做的事情、交流的對象、交談的方式在長久以來在不斷重複經歷之下,深植在腦海中。

我們可以用行為心理學裡「操作制約」來看待職場環境,操作制約的核心概念是根據行為產生的後果,會影響個體後續再重複相同行為的可能性。在操作制約裡有所謂的「增強」與「懲罰」,「增強」指的是個體喜愛、想要的事物,「懲罰」則反之。在個體做了某種行為後透過增強,給予想要的獎勵;或透過懲罰,施予不想要的刺激,來影響個體(實際上行為心理學對操作制約有更詳細的分類跟探討,這邊因為篇幅關係無法做太詳盡的說明)。

如果你已經工作一段時間應該會有所體悟,常常甚至不需要主動學習,剛進入職場後就會有人來告訴你可以這樣做不可以那樣做、什麼時候要做什麼否則就會大難臨頭等等那些所謂職場的淺規則或「不成文的規定」,到頭來我們甚至不會去思考為什麼要樹立這些莫名的規章。

當音樂課只剩下畫答案卡的聲音

時間拉回更早更早——或許進入職場前我們早就是這樣學習跟成長的,還記得國小的時候學校的「嘉獎」、老師打的「甲上上上上上 / 特優」、「小過」、「大過」,現在回想起來真的很沒有意義 ——但當時我們又怎麼會意識到呢?當社團時間只剩下自習、當我們的音樂課只剩下畫答案卡的聲音,也許在熱衷於那些無聊大人訂下的守規矩遊戲時,我們的青春就這樣被被惡狠狠地蓋上了合格認證。

十幾年過去,開始工作、進入社會後,這樣的情況似乎一點也沒有減少。無聊的大人們養出另一世代壞掉的大人( 誰知道呢,也許我才是壞掉的那一邊? ),許多人自以為當上老闆就擁有對別人所有的控制權,慣老闆的八卦早就不算新聞、另一部分人成了訓練有素的職場玩家,想盡辦法奉承巴結上司、深諳遊戲規則的這些人,教會了社會新鮮人「是非對錯」,也間接增強了這種盲目尊崇權威的社會結構,即使是在寫這篇文章的當下,台灣社會裡也許也還充斥著這樣的的公司職場環境。

盲目地追求高薪是一種慢性病

現在在台灣,軟體工程師似乎已經成為一種趨勢,許多人一窩蜂投入軟體產業、補習板根線上課程處處林立,但我總覺得許多這樣做的人再做決定之前都沒有思考過這樣的決定是否真的適合自己、或是跟價值觀是否相符。在這樣的情況下,即使後來這些人進入軟體產業,成功成為軟體工程師,終究還是會因為得不到成就感而無法堅持下去。

「知道方法的人會去工作,而知道這個人為什麼要工作的人,就會成為他的老闆」 - <狼與辛香料>

不管在看這篇文章的你是誰,相信我,就算你是工作者、就算很多人告訴你要找一份穩定的工作、過一個安穩的生活,你永遠都有選擇權。打開選擇權,在職場裡面若發現自己意識到思考的不對勁,就思考看看你是否適合現在的環境、你跟每天相處的同事是否是同一類型的人?

不要勉強自己,你可以選擇一份安穩的生活,也可以選擇踏上尋找個人定位的旅程  — — 即使身邊願意這麼做的人寥寥無幾。而有時候你需要的只是多一點思考。

所以呢?

所以關於今天問題的結論,我會說職場環境所帶來的負面影響對那些了解自己、清楚人生目標的人們是不會有作用的。所以就算現在職場上依舊有許多我們看不慣的陋習,只要願意花時間思考什麼才是最重要的,也許在了解到工作不過是追求個人目標過程中的一種手段後,那些淺規則在你眼中就會變得渺小且微不足道了。

就是要一起拖拖拉拉!(二) - 利用 DataTransfer API 實作拖拉元素間的資料傳遞

上週提到了瀏覽器拖拉時會陸續觸發的幾個事件,我們知道了對可拖曳物件 (draggable) 進行拖曳與放開時以及拖曳物件滑過目標物件上方時,都會觸發對應的事件。今天我想研究看看在事件與事件之間可以做什麼事情,瀏覽器提供了 DragEvent 這個 API ,讓我們可以在開始拖曳時夾帶資料,並在拖曳結束放開時取得該筆資料。

Outline

  • DataTransfer 物件
  • 實際應用 DataTransfer API

DataTransfer 物件

上次有提到在瀏覽器操作中的拖曳功能,必須要自己搭配事件處理來實作,但其實有一些拖曳行為是預設就是有效,像是網頁連結和圖片的拖曳,以及文字選取的拖曳(反白)。而在這些以外的元素如果要觸發拖曳就必須使用 draggable 屬性來達成。

而在拖放事件的最一開始, ondragstart 事件被觸發時,我們其實可以利用事件物件(也就是事件回呼函式內的第一個參數 event ) 裡面的 dataTransfer 物件來賦予被拖放物件想要挾帶的資料,使用方式如下:

let dragger = document.querySelectAll('#dragger') 

dragger.addEventListener("dragstart",function(e){
	e.dataTransfer.setData('text/plain', 'This text   may be dragged')
})

dataTransfer.setData

dataTransfer 物件負責處理拖曳行為之間的資料傳輸,這個物件裡面有兩個方法,分別是 setDatagetDatasetData 使用方式如下:

dataTransfer.setData(format, data); 

這個 API 有兩個參數,分別是:

  • format :想要挾帶的資料格式,使用的是網頁常見的 MIME 格式字串,如文字格式就是 text/plain ,關於 MIME 型別可以參考 MDN 說明
  • data : 想要挾帶的資料

dataTransfer.getData

而在拖曳行為結束觸發 drop 事件時,則可以反過來利用 dataTransfer.getData 來取得前面挾帶的資料。

dataTransfer.getData(format); 

記得必須透過同樣的格式字串來取得同一個參數。

DataTransfer.types

回傳在 ondragstart 時透過 setData API 所設定資料的資料格式,因為可能不會只設定一種格式的資料,所以會以陣列的方式來表示。

實際應用 Datatransfer API

接下來我們就試試看根據上面的說明,如何應用到實際的拖拉操作流程上,修改上次的範例來做說明,先做一個輸入框來設定拖曳時想要挾帶的數值。

let dragDataInput = document.querySelector('#dragDataInput')

<div class="row align-items-center">
    <div class="col-2 d-flex align-items-center">
            <p class="m-0">輸入想要挾帶的資料:</p>
    </div>
    <div class="col-10">

      <input type="text" id="dragDataInput" class="form-control">
    </div>
</div>

之後在原本的 dragststart 事件處理裡面,利用 dataTransfer 設定夾帶資料,這邊資料的格式就用字串做表示。

dragger.addEventListener("dragstart",function(e){
   dragTemp = e.target
  e.dataTransfer.setData('text/plain', dragDataInput.value)
})

後面一樣在 drop 事件就能夠透過 e.dataTransfer.setData 收到前面設定的資料了,我把它掛到拖曳完成的元素上。

dropper.addEventListener("drop",function(e){
	...
  let dragText = e.dataTransfer.getData('text/plain') 
  dragTemp.append(dragText)
  e.target.style.color="#fff"
	...
})

完整範例可以在這裡看到。

參考文章

https://developer.mozilla.org/zh-TW/docs/Web/API/HTML_Drag_and_Drop_API

就是要一起拖拖拉拉!(一) - 瀏覽器 Drag and Drop API 操作

Drag and Drop 在一些網頁產品裡面算是蠻常見的應用,使用起來的效果也常常讓人印相深刻,在像是 Trello、Asana、Cakeresume 等需要進行一些計畫安排或是畫面規劃等互動性較強的工具,都會看到它的蹤影,由於之前的工作內容剛好都沒有機會碰到,最近又剛好想要研究,今天就讓我們一起來研究相關的 API 吧。

Outline

  • 基本概念
  • 宣告 draggable 與 droppable 元素
  • 可拖曳 (Draggble) 元素上的事件
  • 可拖曳 (Droppable) 元素上的事件
  • 實作 Drag and Drop 範例
  • 總結
  • 參考資料

基本概念

瀏覽器的拖拉功能基本上是由一連串的事件觸發而組成。在要被拖拉的元素上要先以 draggable的 屬性宣告,告訴瀏覽器這個元素是可以被拖移的,而在能夠放下拖拉元素的另外一個元素,相對的也必須以 dropabble 屬性宣告,才能夠接收拖移過來的元素內容。

而這一連串拖移的動作又可以拆分為幾個發生的時間段,說到這裡,對瀏覽器比較有概念的人應該可以猜到了。沒錯,這些時間段在瀏覽器內都是一個對應會被觸發的事件 (以下使用 Camelcase 做表示):

  • 開始拖移 (onDragStart)
  • 拖移中 (onDrag)
  • 拖移進入某個元素 (onDragEnter)
  • 拖移經過某個某個元素 (onDragOver)
  • 拖移離開某個元素 (onDragLeave)
  • 結束拖移 (onDragOver)

而拖拉期間與元素互動的方式,將會由開發者利用這些事件觸發來決定,詳細可以參考 MDN 官方網站,接下來會試著實作出基本拖放功能範例,以此說明一些常用到的拖拉事件觸發是如何發生。

宣告 draggable 與 droppable 元素

首先我們要先做出一個可拖曳的跟可放置的元素。分別在兩個素上將 draggable 屬性與 dropabble 屬性宣告為 true (這個專案使用 Bootstrap 作為輔助,後面會有範例)。

<div class="container"> 
  <div class="row justify-content-center"> 
    <div draggable="true" class=" box box-dragger"></div>
    <div droppable="true" class="box box-dropper"></div>
  </div>
</div>

之後,我們來看看上述提到的每個事件代表的意義與被觸發的時機,才能在實作時知道什麼時候該用什麼事件來搭配完成功能。

可拖曳 (Draggble) 元素上的事件

DragStart 事件

這個事件只會在被拖曳元素剛開始被拖曳時被觸發一次,我們可以試著在 .box-dragger 元素上宣告 dragstart 的事件監聽,接著就可以試著拖曳看看。

dragger.addEventListener("dragstart",function(){
 console.log("drag start!!!!")   
})

DragEnd 事件

dragend 事件會在拖曳結束時被觸發,應該很好理解,利用以下的程式碼片段:

dragger.addEventListener("dragend",function(){
 console.log("drag end!!!!")   
})

之後試著拖曳 .box-dragger 元素後再放開就可以看到上述的事件被觸發。

Drag 事件

drag 事件會在可以拖曳元素被拖曳期間持續被觸發:

dragger.addEventListener("drag",function(){
 console.log("draging!!")   
})

可以看見一如上面說的 dragstart 事件只會被觸發一次,而 drag 事件會在拖曳期間持續被觸發。

可放置 (Droppable) 元素上的事件

DragEnter 、DragLeave 事件

這兩個事件的觸發分別會在被拖曳元素進入可放置元素被拖曳元素離開可拖曳元素時發生:

dragger.addEventListener("dragenter",function(){
  console.log("dragenter")
})

dragger.addEventListener("dragleave",function(){
  console.log("dragleave")
})

試著把元素拖曳進可放置元素再離開,可以發現 dragenterdragleave 分別被觸發了一次。

DragOver 事件

dragover 這個事件名稱代表的意義應該不難理解,意思是當可放置元素上有拖曳中的元素經過,就會持續觸發,有點像是 CSS 裡面的 :hover 所代表的使用情境,試著使用下面的程式碼試試看這個事件的綁定吧:

dropper.addEventListener("dragover",function(){
  console.log("dragover")
})

順利的話應該會看到以下結果:

Drop 事件

在 MDN 裡面, drop 事件的定義是「一個元素或文字選取區塊被放置至一個有效的放置目標時觸發。」,但想要順利的觸發這個事件必須要注意的一點是,在 drop行為發生之前的 dragover 事件的預設行為會阻止 drag 行為的完成,也會讓 drop 事件無法被觸發。

因此必須在 dragover事件裡面停止預設的行為,這可以用 preventDefault 方法來達到。對這個預設行為有興趣的可以先參考官方說明,這部分預計在下一篇文章會有更詳細的說明。

dropper.addEventListener("dragover",function(e){
   e.preventDefault()
})
dropper.addEventListener("drop",function(e){
  console.log("drop!")
})

實作 Drag and Drop 範例

現在就讓我以前面的說明為例,來看看如何做出能夠可以把 dragger 拖放到 dropper 元素內的拖曳功能吧,我們已經宣告完 draggable 以及 droppable 的元素了,接下來只要利用事件處理就能夠完成拖拉互動了。

現在我們有兩個方塊,左邊藍色方塊是前面提到的 dragger 右邊則是 dropper ,目標是讓藍色方塊可以被拖曳拉到右邊另一個方塊,而一個很基本的構想是在 drop 事件觸發時,拿掉原本的舊藍色方塊,並把藍色方塊複製一份,放到右邊橘色方塊裡面

我們可以用另一個常見的 Web API 叫做 appendChild ,原本是用來在元素內掛載新的元素,不過如果所掛載的對象是一個已經存在的元素,就會變成是移動元素的效果(驚喜不驚喜意外不意外?沒關係我也是現在才發現),可以參考 W3School 的範例

所以在 dragstart 發生時,我先試著把藍色方塊的內容暫存到另外一個變數裡面。

let dragger = document.querySelector(".box-dragger")
let dropper = document.querySelector(".box-dropper")
let dragTemp;

dragger.addEventListener("dragstart",function(e){
  dragTemp = e.target
})

接下來要記得上面提到的,在 dragover 裡面要阻止瀏覽器預設行為。

dropper.addEventListener("dragover",function(e){
  e.preventDefault()
})

這個時候 drop 事件已經可以順利被觸發,只要在 drop 事件觸發時,在 event.target 也就是 dropper 裡面掛載藍色方塊的元素,就可以完成移動。

上面會看到兩個方塊沒有對齊是因為我一開始有給一些 margin ,不過從開發者工具裡面可以確定藍色方塊已經被拖曳放到橘色方塊之中。

再搭配適合的事件觸發以及適當的樣式調整,像是 dragenter 時改變顏色、 dragleave 時復原,就可以讓拖曳的元素經過可放置元素時產生顏色的改變來提示使用者。

最後在 drop 事件觸發並掛載後,調整一下元素的 spacing 可以看到比較好看的效果了。

想了解完整程式碼的話,我把這個 Demo 放在 Codepen

總結

其實拖拉互動效果並沒有非常複雜,可以想成瀏覽器提供給我們從拖拉開始到結束,一連串的時間段,讓我們可以在期間自由地做互動效果的調整,只要知道什麼時候該做哪些處理,基本上就是網頁元素的互動而已。

而想要達成拖拉互動效果也並不是只有一種方法,也沒有最正確的答案,只要熟悉DOM 元素的操作,能達到心中想要的效果我相信都是可以的。下一章節一樣會針對瀏覽器這個拖拉功能做稍微深入一點的研究,希望今天的主題能夠帶給你一些新的啟發,那麼下次見啦!

參考資料

個人技術站一把罩!部落格建置大全(二)- 將 Github Page 串上自己的域名

上週提到了使用 Hexo 這個工具來架設個人部落格,並放到自己 Github Page 上的方法。這次我們要來看看怎麼把架好部落格的 Github Page ,串上自己擁有的域名( ex. blabla.com ) ,所以在這篇文章內將會對 DNS 與相關設定有一定部分的講解跟介紹。有興趣的人可以先用 https://www.cowboybebop.space/ 來觀看設定完成的結果,這個域名應該還會保留 1~2 個月左右。

Outline

  • 域名購買與域名商介紹
  • 什麼是 DNS ?
  • A Record 與 CNAME
  • A Record 與 CNAME 設定
  • Github Page 的域名設定
  • 總結
  • 參考資料

域名購買與域名商介紹

首先在開始進行串接之前,你要先有域名,而域名可以透過域名商來做購買,這些域名商有很多,相同域名在不同商家的價格也會不太ㄧ樣,或是某些域名只能在某些域名商買得到,這邊推薦幾個我自己用過的:Gandi網路中文Google Domains 。這篇文章會用 Gandi 來做示範,但其實這些網站上購買的流程都差不多。

在網站上購買完域名之後,就可以在 Domains 頁面看到自己擁有的域名。點擊進入各個域名就可以前往個別的設定頁面,今天會以我之前買的多出來的域名 cowboybebop.space 為例。

找到 「 DNS Records 」的分頁,會看到很多,會看到一堆很像神秘魔法咒語的設定值,今天不會逐一介紹,但這邊我們有必要先了解一下什麼是 DNS。

什麼是 DNS ?

DNS (Domain Name Server) ,直翻成中文是網域名稱伺服器,可以理解為「負責處理域名的伺服器」,為什麼域名還會需要處理呢?我們在使用網際網路瀏覽網頁時所看到的內容,其實是從你使用的裝置(手機、電腦或平板)連出去對另外一台裝置(也就是伺服器)索取回來的。而兩者之間的溝通,則是以 IP 位址來找到正確的伺服器,IP 位置就像現實世界的門牌地址ㄧ樣,讓其他人可以順利找到對應的位置。

但是我們每天經常使用的網站這麼多,像是 Facebook 、Gmail 、Dcard、Slack,如果世界上所有的網站,都使用 IP 位置來做位置的辨別,那麼你可以想像,我們可能要熟記好幾種數字組合才能夠維持正常的生活。

DNS 的出現就是為了解決這個問題,如同我們現在在生活中所經歷的,只要在搜尋列上輸入 facebook.com 就能夠快速前往臉書的網站,這是因為 DNS 透過給每個 IP 位置取個名字,把這些原本只有電腦能夠理解的位置數據,變成人類也很好記憶、理解的名稱,讓各種網站應用能夠更加融入我們的生活中。

A Record 與 CNAME

DNS 相關的設定其實有很多,我們就是透過這些設定來告訴 DNS 如何將域名配對到正確的 IP 位址。今天會介紹其中最常會碰到的兩個設定值:

  • A Record : A 代表 Address ,也就是紀錄 IP Address 與網域名稱的配對,這個紀錄是在設定 Domain Name 時最重要的項目。

  • CNAME : CNAME 是網域名稱的別名,用來將子域名指向另外一個主機的域名,最常見的就是將 www.abc.com (子域名) 指向 abd.com(購買的主域名) ,避免使用者因為意外而找不到網站(夠買主域名後,就可以從域名商的設定後台,新增子域名,是不需要另外付費的)。

  • ALIAS : 與 CNAME 記錄很相像,差別在 CNAME 別名只能用於子域名,而 ALIAS 記錄能夠用在主域名,但這麼做會影響對主域名的域名解析,所以不能與 A 紀錄同時使用。

在了解什麼是 A Record 與 CNAME 與 ALIAS 之後,就讓我們繼續往下設定。要達到串接域名的效果其實不只有一種方式,為了幫助理解以下提供兩種可能的方案。

方案一:直接透過 A Record 設定

因為今天的目標是要將自己的域名可以接到 Github Page。如果只是單純的想要把主域名 ( cowboybebop.space ) 串到 Github Page 上,只要將它指向 Github 伺服器 的 IP ,也就是:

185.199.108.153

就可以讓域名順利解析為 Github 主機的位址了,這部分的相關資訊可以在 Github 官方說明文件內找到。而因為我們不只要找到 github.io 還要找到我們的個人頁面 moojitsai.github.io ,這部分必須在 Github Page 的 Repo 內做對應的設定,會在接下來的內容中提到。

方案二:CNAME 搭配 ALIAS 設定

設定 CNAME 的目的是因為有時候我們希望使用者是透過加上 www 前綴(www.cowboybebop.space ) 的網站域名進入我們的網站,在這種情況下只要用戶不記得或不知道要輸入 www. 前綴,就會沒辦法順利找到我們的網站應用,所以我們必須要將這些用戶重新導向到我們想要的域名。

通常會在域名前面加上 www. 當作網站位置是為了提醒用戶,這是一個公開的網站,藉此提升體驗。但其實加或不加,並不影響網站的功能性,只要有對應正確的設定即可,因為加上了 www. 前綴的域名,相對於主域名來說只是子域名而已,只是站在 SEO 或是追蹤流量的角度,一般還是建議兩種方式選一種(要不要加上 www.) 並且統一使用,否則 Google 爬蟲會將這兩種域名視為兩個不同的網站。

在方案二中,我想要以將原來沒有 www. 前綴的主域名統一導向到有 www. 前綴的子域名的方式來完成:

cowboybebop.space/    -----自動導向到------->    www.cowboybebop.space/

就像上面提到的,ALIAS 記錄可以用在主域名的別名設定,這個設定剛好可以達成上面提到的導向到具有 www. 前綴域名的效果。因此我們要這麼做:

  1. 將主域名的 ALIAS 設為加上 www. 前綴的子域名
  2. 將加上 www. 前綴的子域名, CNAME 設為 Github Page 的 URL

提供下圖做為參考,只要看第一跟最後一筆紀錄就可以了,其他是系統預設的設定。

透過這樣的方式,我們就能夠完美的將有 www. 與沒有 www. 前綴的域名都導向到 Github Page 頁面囉!而且統一使用的會是 www.cowboybebop.space,也符合前面提到的建議使用方式!

Github Page 設定

在 Github Page 對應 Repo 內的 Setting 頁面裡的 「 GitHub Pages 區塊」,可以看到 Custom Domain 欄位,在這邊我們必需告訴 Github 我們想要將自己的 Github Page 與哪一個域名串接,才能夠讓我們自己的域名,正確對應到原來個人的 Github Page Url。

設定完之後,記得把 「Enforce Http」的設定打勾,這樣一來,我們的域名就自動有了 https 憑證,不需要在自己簽發,因為 Github 幫你做掉了,這個功能超方便!

設定完成後應該可以在 https://www.cowboybebop.space/ 看到頁面了!

總結

域名相關的設定對於網頁相關工作者來說,初期並不是那麼容易了解,因為其中還有許多細節跟環節,像是 DNS 相關的設定也不只有今天介紹的三種,還有其他適用於特殊使用情境的設定,只不過比較特定。像是 ALIAS 紀錄我也是在寫這篇文章的期間才發現,並知道怎麼設定的。

而因為每次 DNS 設定更新之後,必須等待 DNS 伺服器解析後才會生效,因此來來回回也失敗了好幾次才終於達成目標,這部分是我覺得最麻煩的。也感謝我的顧問團隊 Max 的幫助。那麼這個 Github Page 的部落格系列就暫時到這邊,希望大家都能夠有記錄自己成長過程的專屬頁面囉,那麼就下次見,有發現什麼有趣的東西再來跟大家分享!

參考資料

Managing a custom domain for your GitHub Pages site

CNAME 紀錄

為什麼越來越多的網站域名不加「www」前綴?

我室友 Max

個人技術站一把罩!部落格建置大全(一)- 使用 Hexo 搭配 Github Page 建置自己的部落格

這是我參加六角全馬鐵人挑戰的第二週,在比賽的一開始,就讓我來分享如何在 Github Page 上面架設自己的個人頁面,並串上自己購買的網域名稱(如果有的話)。相信各位工程師們多少都會聽過或看過其他工程師們使用 Medium 當作自己的部落格,對其他人分享自己經歷及技術,或是成長過程中領悟到的見解。

但如果你想對這個屬於自己的空間有更高的掌握度,或是想做一些比較客製化的排版視覺,那麼架設個人專屬的部落會是一個不錯的選擇。因此這邊推薦使用 Hexo ,作為今天介紹的主角。當然,方法沒有絕對,你也可以選擇自己從零開始開發,但我認為工程師的職涯如此漫長,有很多問題等我們解決,因此在適當的時機使用適合的工具,有時候是必要的。

Outline

  • Hexo 介紹及專案建置
  • Hexo 專案架構
  • Github Page
  • 將 Hexo 專案部署到 Github Page 上
  • 總結

Hexo 介紹及專案建置

Hexo 是一套可以快速幫你建置個人部落格的工具,在官方提供的頁面你可以找到很多別人做好的部落格模板,並直接套用到自己的專案。

首先讓我們先把 hexo 的建置工具裝到系統內,使用以下指令:

npm install -g hexo-cli

之後就可以利用 hexo 指令創造一個新的部落格專案。

npm init <要創造的 Hexo 專案資料夾名稱>

接下來進入剛剛建好的專案資料夾再用這個指令把 hexo 本地測試 server 架起來。

hexo server 或 hexo s

沒錯,這樣一來在本地就可以看見即時的變更囉,很方便吧!

Hexo 專案結構

這邊先介紹一下 Hexo 的資料夾分佈,以及功能。首先在根目錄可以看到幾個比較重要資料夾:

  • public : 存放編譯後的靜態 html 檔案,基本上不會需要改動裡面的內容

  • scaffolds:存放文章模板的地方,新增文章的時候可以選擇要用哪一種模板,以我為例,在寫 JS 文章跟後端 Ruby on Rails 文章的時候就可以用不同模板來產生不同分類與不同標籤的文章,不用每次都另外再改。使用模板來新增文章的參考指令:

      hexo new <<Scaffold Name>>  <<Article Name>>
    
  • source : 存放部落格文章原始檔案, Hexo 內的文章通常以 Markdonw 來表示內容,而 Markdown 在很多地方都通用,非常方便。

  • themes : Hexo 官網可以選擇許多別人做好的主題,在官網找到喜歡的主題後,就可以下載並放到這個資料夾,然後記得在根目錄的 _config.yml 檔案裡的 theme 設定改為對應的主題名(資料夾名稱) ,以這個範例來說就是 landscape ,而對應的主題資料夾裡面則包含了外觀相關的原始碼(如 HTML / CSS / JS),建議在必要的時候再去修改這些原始碼,否則盡量修改對應主題資料夾裡面的 _config.yml 檔案(與根目錄的同名設定檔不同)會比較好。

Github Page

什麼是 Github Page ? 在對 Hexo 專案內容有了基本的了解後,讓我們繼續往下看。 Github Page 是 Github 提供的、能讓開發者利用 git 的形式直接配置好靜態頁面的功能,非常好用,許多實驗性的作品或專案也都會透過這樣的方式來呈現,而今天我們就會嘗試將 Hexo 用 Github Page 的方式來作部署。

首先在 Github 上創建一個新的 Repository ,Repository的名字依照官方說明,必須遵循以下規則

( 記得 username 是你自己的 Github 帳號,不要輸入錯了 ):

<username>.github.io

然後就會得的一個新的 Repository ,待會我們 Github Page 就會是以這個 Repository 的內容為主來做對外顯示。

其實剛剛輸入的 Repository 名稱 <username>.gtihub.io 就會是你個人Github Page 的網址,可以直接透過瀏覽器輸入網址找到,但因為目前還是空的,所以還不會有東西,我們先在電腦本地將這份 Repository clone 下來 :

https://github.com/moojitsai/moojitsai.github.io.git

並新增 index.html 檔案做個測試,因為 Github 預設會去尋找這個檔名的檔案作為進入點。

新增完成後只要再用 git push 推回剛剛的 Repository 上,就會有 index.html 檔案,應該就可以從你的個人 Gtihub Page網址看到了(如果沒有看到再來問我)。

將 Hexo 專案部署到 Github Page 上

建立完新 Gtihub Page 後,我們來看看怎麼把這整個部落格專案放到 Github Page 上面,其實不難, Hexo 大多幫你做好了,只要設定檔配置正確就沒問題。

首先找到 _config.yml 這個檔案,然後在 deploy 這個設定下輸入你對應的 Github Page 的 Repo 位置,並把 type 寫為 git ,就完成基本設置了:

然後在部落格專案目錄底下裝上官方提供的 Git 部署套件 hexo-deployer-git

npm install hexo-deployer-git --save

最後因為我們文章內容並不是網頁可以直接看得懂的格式,像文章內容就是用 Markdown 來撰寫,所以在部署上 Github 之前我們要先產生部落格專案所需要的靜態檔,使用以下指令來產生:

hexo generate 或是 hexo g 

完成後就可以部署了:

hexo deploy 或 hexo d

總結

今天的內容主要是紀錄部落格架設的過程,因為當初在使用 Hexo 的某些設定及功能時,某些官網寫的資料並不是那麼明確,還是要自己實驗過或是去看原始碼才比較會知道怎麼做,因此還是寫了一篇文章記錄下來,順便分享給有興趣的各位,下一篇要分享的是如何把今天設置完放到 Github Page 的網頁與自己購買的域名做串接,應該會蠻有趣的!可以先看看我最近串好的域名: https://www.muji.dev

參考文章

Hexo 官網

Managing a custom domain for your GitHub Pages site

Github Page 說明

瀏覽器的時光機—歷史堆疊、 pushState 與 replaceState API

或許 pushStatereplaceState 這兩個詞你可能沒聽過,但是瀏覽器「上一頁、下一頁」功能你一定不陌生,其實這兩個 API 是瀏覽器提供給開發者操作瀏覽紀錄用的,透過這兩個 API 並搭配事件處理,我們就可以將瀏覽器預設的「上一頁下一頁」修改成我們想要的客製化結果。

  • 使用情境說明
  • pushState / replaceState 與點擊新連結有什麼不一樣
  • pushState / replaceState 與 Stack 結構
  • pushState / replaceState 使用方式
  • onpopstate 事件
  • 一個栗子

使用情境說明

最近接到了必須修改瀏覽器歷史紀錄行為的需求,所以順便寫了篇文章整理下來。 在公司負責的產品是類似 AWS 那樣資源控制的後台介面,前端部分雖然使用了 React 作為主要工具,但架構上有點像是由 Webpack 打包出靜態檔, 之後交由後端 Server 來處理前端內容,所以無法直接將路由控制交給 React 作使用。

什麼意思?一般 SPA 原理是由 JS 產生動態內容並即時掛到 DOM 上,但這個產品流程上是這樣:

  1. 在開發階段由 Webpack 將源碼打包成瀏覽器看得懂的程式碼,一個工具模組對應一個頁面
  2. 在產品運行階段由 Node Server 來處理剛剛 Webpack 打包好的靜態檔案
  3. 因此除非在後端做更詳細的路由修改,否則無法作更複雜的路由變化(ex.巢狀路由)

而在這種情況下如果某一工具模組需要以多個頁面來進行資料的設定流程,就沒辦法像一般我們所習慣的使用框架的 Router 函式庫 — React-Router 或是 Vue-Router 讓路由與頁面動態地做搭配,因此 pushState 及 replaceState 這兩個 API 就特別適合這個時候拿出來使用。

(可能還是可以,只是在當時沒有太多時間做調查的情況下,我當下所做的決定就是使用這兩個 API 來解決)

首先讓我們先來了解 pushStatereplaceState 在做什麼事情。

pushState / replaceState 與點擊連結有什麼不一樣

兩種方式都會改變瀏覽器網址列的內容,但 pushState 與 replaceState 差別在於**會不會發送新的 Http Request,**這點應該不難發現,一般我們點擊新連結時,因為會發送 Request ,對後端重新要求一份的 HTML 內容,因此這個時候會看到瀏覽器重新刷新了頁面。

相對的 pushState 與 replaceState 這兩個 API 則單純只會修改網址列的內容,而不會刷新頁面,只是如果使用者在網址列按下 Enter 或重新整理的話,就一樣會發出 Request 。這與單純點擊的效果相同。

瀏覽器記錄與 Stack 結構

關於今天提到的兩個 API,如果查詢 JS 的 MDN 文件的話,關於 pushState 會得到以下的說明:

In an HTML document, the history.pushState() method adds a state to the browser's session history stack.

replaceState 則是 :

The replaceState() method modifies the current history entry, replacing it with the state objects, title, and URL passed in the method parameters.

讀完這兩段雖然可能沒辦法馬上弄清楚說明的意思,但從內容我們可以看到一個蠻重要的部分:「 history stack.」這是否說明瀏覽器記錄與資料結構裡的堆疊 ( Stack ) 有關?沒錯,瀏覽器裡的歷史紀錄就是以堆疊的形式儲存下來供使用者作使用,首先讓我們來看看堆疊是什麼,可以看看以下的模型圖。

我們可以使用 pushpop 兩種方法分別對堆疊結構加入一筆新的資料或是取出最後一筆資料,因此,關於堆疊有一個很常見的描述就是「先進後出」。其他前端常用到的資料結構可以參考我之前鐵人賽的文章

所以堆疊套用在歷史紀錄上是怎麼回事呢?每當我們從同一個頁面點擊網址轉到新的頁面時,就是在對歷史紀錄的堆疊使用 push 方法新增一筆新的瀏覽紀錄 ,而當我們執行上一頁往前瀏覽時,就像是在使用 pop 方法取出最後一筆瀏覽紀錄(網頁位置)。

pushState / replaceState 使用方法

history.pushState(state, title, url);

現在讓我們回到這兩個 API 上 , pushState 與 replaceState 都接受三個參數,分別是:

  • state:每個 history stack 都會可以給一個 state 物件。
  • title : 更新後頁面的 title 標籤內容設定,不過根據官方文件說明,目前為止大部分瀏覽器都會忽略他,因此最安全的方法是傳入空字串,不做任何修改。
  • url : 執行該方法後想要更新的想要更新的 url

但是這兩個長得這麼像的方法在使用上有什麼不ㄧ樣呢?首先,前面提到這兩個 API 都只會更改網址列內容而不會發出新的 Request (刷新頁面),而它們在使用上其實也差不多,差別在改變歷史紀錄堆疊的方式而已。

pushState 在被呼叫之後會真的對瀏覽歷史紀錄堆疊新增一筆紀錄,所以如果用 console.loghistory.length (歷史紀錄堆疊的長度)印出來看的話會發現長度多了 1 。

replaceState 在呼叫後雖然ㄧ樣會改變網址列內容,但 history.length 的值卻不會有任何改變,這是因為如果用堆疊的方式來看歷史紀錄的話, replaceState 只會修改堆疊的最後一筆紀錄內容,也就是目前的網址列內容。

onpopstate 事件

在瀏覽器上一頁按鈕被執行時,堆疊的最上層,也就是最後一筆瀏覽紀錄會被取( pop ) 出,新的一筆紀錄網址會被更改到網址列內,而這時會觸發瀏覽器的內建事件 — popstate 事件。所以如果有些客製化功能想要搭配上一頁按鈕執行,就可以使用這個事件。使用:

window.onpopstate  = (event)=>{ //事件函式內容 }

就可以寫入自訂的事件內容。

一個栗子

說了這麼多,來舉個實際的例子看看運作方式如何吧,現在我有三個按鈕,每個按鈕點擊後各自會呼叫有不同參數的 pushState 方法,而這時因為是第一次進入頁面,所以 history.length 是 1 。

所以如果我依序點擊 first 、second 、 third 按鈕之後,應該會在瀏覽器歷史裡新增三筆紀錄堆疊。

可以從上圖看到目前的網址內容變成 third.html ,但頁面仍然是原本的內容,沒有刷新,而瀏覽紀錄堆疊長度也真的變成 4 。這時的堆疊裡應該分別是:

first.html -> first.html -> second.html -> third.html 

堆疊裡的最後一筆紀錄是 third.html ,所以現在如果點擊上ㄧ頁按鈕,理論上會回到 second.html 堆疊。

透過上圖可以看出確實如此,而且搭配 onpopstate 事件把 event 物件印出來可以看到在 pushState 時傳入的 state 物件的內容 也會隨著被 pop出來。還有一個可以注意的地方是我們在做以上這些操作時,都沒有任何頁面刷新的情況發生,但確實改變了瀏覽紀錄堆疊。

總結

上面總共提到了 pushState、replaceState 及 onpopstate 事件,也提到歷史紀錄堆疊的存放方式,還有pushState、replaceState 與一般點擊連結的差異,只要好好活用這些 API 方法的特性,就可以達成 主流框架 Router 如 React-Router 或 Vue-Router 那樣不發 Request 就能頁面的效果(其實推測一下的話這些 Router 函式庫裡面應該也是使用這些方法)。

那麼就寫到這邊,最近剛好有這樣的需求需要比較特別的解決方法,剛好看了一下以前沒有深究的部分,覺得蠻有趣的,就順便記錄跟整理下來,下次有不錯的東西再寫下來跟大家分享囉!

JS 原力覺醒 Day30 - 我是怎麼活過這三十天的?

總算來到最後一天了,最後一天不會有技術內容,只會有很純的純 Mur Mur,想聽的再請留下。最後我打算記錄一下這三十天的感受,給其他沒參加過鐵人但是正在猶豫要不要參加的朋友參考。

普遍看到參賽方式有幾種情況:

  • 精明準備型:囤好囤滿 30 天,完全事先囤貨所以內容超精緻
  • 微囤貨:不事先準備太多,只囤幾天貨用來緩衝
  • 硬派:「 什麼!?不就是要現學現賣才叫做鐵人嗎? 」的類型

老實說我認為如果不事先準備的話,那麼能不能完賽跟主題的選擇還有自身對主題熟悉度會有很大的關係,所以如果你也正在思考要不要參加,可以從這點著手。如果主題是你想寫但不熟,可以考慮現在開始到下次開賽前先慢慢累積文章量;或者你覺得對主題比較上手,可以挑戰看看自己在短時間內對知識的理解程度。

30天連續發文不只考驗技術

經過評估,我走的是微囤貨路線,因為我是在 9/2 開賽前幾天才知道有鐵人賽這個活動,剛好我的下一個近期目標是對 JS 這個語言更有系統性的認識,當時是覺得這個活動可以用來挑戰一下自己,也正好可以之前寫的 原子化學習 裡面把知識最小化的學習方式做實作驗證,所以想了一下大概要寫什麼之後就跳坑了。到了開賽期限 9/16 前一天,我硬生出大約 10 來篇準備留著緩衝。

沒想到正式開賽才發現我完全低估每天必須發文所帶來的壓力了,因為事先沒有累積太多文章的關係,我幾乎每天都在想著下一篇文章的內容怎麼寫、大綱怎麼擬定、要怎麼畫出核心概念圖才能讓人比較好理解之類的問題。這樣子的狀況持續到大概第 20 篇的時候是最痛苦的,因為越後面的主題我越不熟,需要越多時間,而前面的幾篇卻也因為思考解說方式跟準備圖例的關係花了不少時間,留下來的緩衝時間所剩無幾。

在這個時間點想繼續寫覺得吃力,想放棄又覺得不太對,瞬間覺得自己好像在跑一場已經完成 2/3 ,明明心裡知道快結束了但眼前就是還看不到終點線的尷尬窘境。所以我深深覺得鐵人賽除了考驗技術熟悉度更考驗筆者的心理耐力。在最後雙十連假那幾天實在是最難受的,雖然咬著牙硬寫完了,但是基本上我是ㄧ邊配獵人邊寫完的(喂

使用工具

工欲善其…咳咳,好廢話不多說,稍微介紹一下我這幾天用來幫助寫文章工具,基本上有三個:

  1. Notion

    在正式開賽之前,我先用 Notion 的 Table 整理了三十天的大綱,雖然最後沒有完全ㄧ樣,不過可以讓自己對文章主題有個底,時間的掌握也比較精準,哪些主題自己比較不熟的話就要預留比較多時間。
    https://ithelp.ithome.com.tw/upload/images/20191015/20106580Hsbd3FYXSf.png

  2. 簡單的流程圖繪製軟體

這種軟體的選擇就比較多,我是選 Sketch,一來之前有學過一點,二來覺得他匯出圖片很方便,雖然他主要是用來前端介面設計的工具,但是拉拉簡單的區塊跟流程箭頭還是很好用的。還有另外一套網頁版繪圖軟體也很推薦:Draw.io

https://ithelp.ithome.com.tw/upload/images/20191015/20106580m8ATiR1MZH.png

  1. 瀏覽器開發者工具:

因為我寫的主題不是製作產品型的主題,許多範例程式碼只要可以馬上確認結果就好,這個時候整個瀏覽器都是我的實驗場 :D

https://ithelp.ithome.com.tw/upload/images/20191015/2010658095DQqsedJi.png

是音樂,我加了音樂

如果你以為我是像老派英雄主義電影裡的主角ㄧ單單靠著強大的意志力就輕輕鬆鬆寫完 30 篇文章練成鐵人那就錯了,我也希望我可以。 我曾經抱著很中二的想法,覺得如果世界上沒有音樂的話,我們幹嘛活著?老實說我現在還是深深這麼想的,大概今後也會一直這麼中二下去。總之最後來介紹一下陪伴我度過這地獄般 30 天的幾首曲子:

  • Tauk - Horizon :

    風格上屬於前衛搖滾,我很喜歡他們華麗的效果器加上風格多變的主奏電吉他,雖然沒有人聲,但總能聽得我熱血沸騰,附上近期喜歡的一首曲子:

    Yes

  • Takami Nakamoto - Ashes:

    這個音樂家的作品風格定位上還是比較偏電子舞曲,一般人聽來可能會覺得比較實驗性或藝術性,但我真的很喜歡各種奇異材質的聲響。想暫時脫離現實生活看一下不ㄧ樣世界樣貌的絕對推薦(建議戴耳機):

    Yes

  • Mariya Takeuchi - Plastic Love :

    這首毫無疑問是經典,我真的很喜歡遍佈整首的 Disco 元素,前奏剛下沒多久眼前就浮現煙霧彌漫然後雷射燈球光芒四射的場景,查了一下定位屬於 City Pop ,City Pop 是在 1970 日本傳統音樂受到西方音樂文化元素的影響而發展出來的獨特曲風,在當時由山下達郎組成的樂團 SUGAR BABE 帶起風潮。而竹內瑪莉雅就是山下的妻子,也是早期非常有名的音樂家之一,這首歌在前陣子 City PoP 復甦的時候出現在我的推薦歌單內,聽過後立刻愛上。

    Yes

  • The Brand New Heavies :

    這個團體是 Acid Jazz (酸爵士) 的經典團體之一,Acid Jazz 緣起於 Disco 文化,在 1980 年代開始變成風潮,當時舞廳的 DJ 嘗試將爵士樂中的樂句加以取樣,融入電子音樂裡面並融合了靈魂樂、Funk、R&B 等曲風,因而吸引了年輕世代跟老年族群的注意,也是一個讓當代大眾重新開始接觸爵士樂的契機。

    雖然在 1987 年由知名唱片經營者 Eddie Piller 與 DJ Gilles Peterson 創立同名自有品牌後才正式被命名,不過 Acid Jazz 這種風格受 60 年代迷幻文化影響至深。( Acid 同時也是迷幻藥的別稱 )所以你常常能在這種曲風裡面聽見運用電子特效達成的迷幻效果,同時又因為強烈的律動感而忍不住開始擺動身體,讓我們來聽聽看 The Brand New Heavies 今年出的專輯同名歌曲,你一定會很喜歡:

    Yes

總結

原本以為在我整理完過去的學習經驗,寫出原子化學習已經是我自己在今年最有標誌性的里程碑了,沒想到又幹了一件突破自己耐力極限的事情啊 (X。老實說好幾次都以為我沒辦法完賽了,我也不知道是什麼讓我可以支撐到最後,也許是賽期後半段開始陸陸續續有人追蹤,甚至有讀者會參與內容的討論以及告知筆誤,讓我覺得有莫名一股一定要繼續寫下去的責任感。

其實後面還想有寫但來不及寫的主題:演算法跟設計模式,但在我寫這篇文章的當下,已經有些厲害的工程師以演算法當成 30 天的主題並且已經或快要完賽了:

透過 LeetCode 解救美少女工程師的演算法人生

前端工程師用 javaScript 學演算法

模組化設計

上面稍微推薦一下幾個很棒的相關系列,接下來完賽後我陸陸續續也會去看之前沒時間看的那些主題,如 神Q超人的 TypeScript 或是 六角校長的職場教學,讀者有興趣的話之後也可以跟我一起惡補回來 ( X 。

JS 原力覺醒 Day29 - Set / Map

ES6 之後加入兩種新的資料結構:Map 跟 Set 。 Map 與 Set 都是像字串跟陣列這樣可以被尋訪的類型,也就是說可以使用 for 迴圈去一個一個查找跟操作他們的值。今天就來說明一下這兩個類別跟使用方式吧!
https://ithelp.ithome.com.tw/upload/images/20190916/20106580lJIWdcHc2t.png

Outline

  • Set
  • Map
  • Map 與 Object

Set

Set 的中文翻譯與數學裡面的「集合」相同,「集合」是某個定義好並且具有相同性質的元素的集合,講白話一點就是「一堆東西」。在 JS 內的集合當然代表「一堆值」,他跟陣列有點像,差別在 Set 能夠讓開發者可以方便快速的儲存不重複、獨特的數值。至於 Set 內儲存的元素內容沒有型別限制,可以是純值也可以是物件型別。

Set 除了具有儲存不重複數值的性質外,在上面還有一些很方便的方法可以直接處理數值,讓我們陸續來看看,首先創造一個新的 Set ,創造新的 Set 很簡單,只要在 Set 的建構子傳入一個陣列即可:

 let set = new Set([1,2,3,'Hello','World',true]) 

在 Set 類別上有許多方法讓我們可以用比較語意化的方式操作 Set 內容:

  • add( value ) : 新增一個元素到 Set
  • clear() :刪除所有 Set 內的元素
  • delete( value) :刪除 Set 內特定的某個元素
  • forEach() : 跟 array 上的 forEach 功能相同
  • has( value ) :檢查 Set 內有沒有對應值的元素,這個功能如果在陣列內,必須透過 indexOf 來檢查才能達成。
  • values() :會回傳 Set 內所有數值
  • size :回傳 Set 元素長度

就像前面說過的, Set 內儲存的是不重複的元素,因此如果有相同數值的元素再次被傳入,這個數值就會直接被忽略。

	set.size //6
  set.add('Hello') 
  set.size //6

對 Set 做巡訪的方式跟陣列很相似,一樣可以用 forEach 方法,甚至 Set 可以很方便的直接轉為陣列 :

 let setArr = [...set]

這個特性非常好用,利用這點我們就可以很快速的過濾出陣列內的重複值!

 let duplicatedValueArr = [1,2,3,5,10,19,10,4,5,6,3,1,2]
 let uniqueArr  = [...new Set(duplicatedValueArr)]

這樣子是不是既方便快速又簡潔? 如果單純使用陣列可能還需要透過 filter 跟外部變數來儲存重複值輔助檢查,使用 Set 的話,這些功夫都可以省去。

Map

Map 也是跟陣列、跟 Set 具有相同特性且可被巡訪的物件型別,差別在於, Map 跟物件ㄧ樣是鍵值的組合,也就是說,Map 同時具有跟陣列ㄧ樣可以被巡訪的特色,同時也有物件儲存任意屬性跟數值的能力。

Map 類型上的方法也與 Set 大同小異,差別在 Set 新增元素的方法是使用 add ,而 Map 內必須用 set 方法 ,且新增元素時必須傳入兩個參數,第一個是要儲存的鍵 ( key ),另外一個是要儲存的數值內容 ( value )。

創造新的 Map 的方式與創造 Set 相同,但由於 Map 是鍵-值對的結構,傳入建構子內的陣列內不能夠像 Set 那樣只是個單一元素,而必須要是個鍵-值的組合,所以我們可以用二維陣列來達成,大概像是這樣:

 let map = new Map([['name','Luke'],['Hello','World']]) 

取得 Map 元素 :

  map.get('name') // Luke 

新增元素 :

 map.set('Greeting','I am Anakin') // { ... 'Hello'=>'World', 'Greeting'=> 'I am Anakin'}

其他像是刪除特定元素或是刪除所有 Map 內元素則都跟 Set 上的方法差不多:

 map.delete('Hello')  
 map.clear()
 map.size

Map 與 Object

Map 其實跟物件ㄧ樣都是 鍵-值 的組合,事實上這些結構相似的類型有許多種,如,那麼使用 Map 相比於使用物件有什麼好處呢?還記得前面提到在 JS 內除了原始型別以外的型別都是物件型別嗎?這代表除了物件以外像是 Array 以及Function 這樣的型別都是繼承自 Object,這其中當然包含 Map

所以這兩種型別才有這麼相似的結構 ,性質相同的部分就不用多說了,但是這兩者還是有一些不差異,這些差異可能足以影響資料存取的複雜度以及程式碼閱讀的難易度,所以我們可以認識一下究竟兩者有什麼不同的地方:

  • 鍵值的類型:

    在物件內的鍵值(或屬性名稱) 必須是字串或是 Symbol。而在 Map 內,鍵值可以是任何型別,這包含任何其他的物件或是陣列 。你當然可以試試看用物件來當作另外一個物件的屬性名稱,不過這個物件會被 JS 強制轉型變成 [object Object] 而變成另外一個字串屬性。

      let o = {} 
      let anotherObj = {} 
      o[anotherObj] = 'anotherObject' // {'[object Object]' : anotherObject}
      
      let theThirdObj = {} 
      o [theThirdObj] = 'theThirdObj' //  {'[object Object]' : anotherObject}
    
  • 元素的順序,在 Map 裡面,元素被新增進去之後,順序就會被固定下來。而在 Object 內則無法保證。

  • 繼承關係:Map 繼承於物件 ( Object ) ,而反過來則否,因此在 Map 上那些方便的方法,在 Object 上無法使用。

      let newMap = new Map()
      console.log(newMap instanceof Object) //true
      console.log(Object instanceof newMap) //false
    
  • 可被巡訪:這大概是最大的差別了,因為一般物件上並沒有提供可以直接巡訪的方法,只能透過 for .. in 迴圈達成,或是必須透過 Object.keys 方法把屬性轉為陣列,但是在陣列 、 Set 跟 Map 上都有 forEach 方法可以直接對裡面的元素做巡訪。

Map 與 Object 使用時機

Map 在操作元素上雖然提供了許多語意化的方法,但有時候我們還是會需要像一般物件那樣方便新增元素的方式,最後我們就來看看兩者各適合怎樣的使用情境:

  • 屬性值:這也是兩種型別最大的差別。在知道屬性值都單純只是字串時,使用一般物件就好,因為 Map 雖然可以儲存任何型別的數值,但是因為使用函式建構子創造物件,且在新增、修改元素時必須透過 getset 函式幫忙,因此速度上會比單純使用物件還要慢。
  • JSON 格式:在需要以 JSON 格式來進行開發作業時,選擇一般物件。因為 JS 內的物件可以很直接的被轉為 JSON 格式,這在進行 API 溝通時非常好用。
  • 順序性: 在 Map 內的元素順序會被保留,因此在處理資料時,如果維持順序的穩定很重要,就可以考慮使用 Map
  • 需要一些特定功能:有時候我們會需要某個函式來取得其他屬性資訊,物件因為存取方便的關係,在物件內的屬性如果是函式,就可以直接被執行,Map 就比較麻煩。

總結

除了前面我們提到的幾個基本資料結構,今天我們又認識了 JS 內新的 Map 跟 Set 兩種新的資料型別。在資料結構選擇上永遠是根據你的需求而定,雖然用簡單的物件或陣列組合或許就可以達到,多認識一些這樣子的資料結構不一定會大幅度增加開發速度,但絕對會讓你在開發時有更多其他潛在更好的選擇來達成你的需求。

JS 原力覺醒 Day28 - JS 裡的資料結構

隨著硬體規格條件的提升, 網站商業邏輯的運作也慢慢從以往的後端伺服器轉移到客戶端,因此前端領域的專業知識就變得越來越重要,隨著前端技術被重視,也開始慢慢出現 React 、 Vue 、 之類前端框架的生態圈出現,而後端則慢慢演變為單純的 API 伺服器負責提供資料的存取端口。同樣的,畫面的互動也是另一個越來越被注重的部分,因此怎麼實作出更精緻優雅的前端介面以及互動邏輯也是前端工程師們面臨的新挑戰之一。這些在在的都考驗了工程師們對 JS 這個語言本身更全面的了解。

如同前面所說,隨著商業邏輯慢慢著重在前端,許多資料的規格訂定也常常會跟隨著介面的結構而有所不同,這些隨著前端邏輯而被暫存在前端的資料,變得有點像是後端伺服器放在前端的副 @本。所以,前端工程師的經驗跟專業,就會在資料結構的選擇與判別使用的時機的能力上顯現出差異,而某些資料結構我們在前面的文章多多少少都有提到一些了。在這篇文章內,我想較全面的,對在開發上,常使用到或常見的資料結構做一些說明。

基本上有三種類型資料結構:

  • 陣列型態的資料結構 :Stack 、Queue
  • 以「節點」為基礎的:Linked Lists、 Trees
  • 在資料查找上非常方便的: Hash Tables

Outline

  • Stack
  • Queue
  • Linked Lists
  • Trees
  • Hash Tables
  • 總結
  • 參考文章

Stack

Stack 具有後進後出的特性,堆疊的概念我相信各位 JS 工程師都已經非常熟悉了,而這大概也是 JS 內最重要的資料結構了,在之前講到執行環境堆疊的時候有提過。以程式的方式來說,堆疊的結構就是一個具有 pushpop 兩個方法的陣列,push 可以把元素放到堆疊的最上層,而 pop 可以把元素從堆疊的最上層拿出來。

https://ithelp.ithome.com.tw/upload/images/20191013/20106580fh2OZ6eKkv.jpg

Queue

Queue,序列 也是 JS 語言的核心部分之一,Queue 具有「先進先出」的特性,還記得我們之前提到的 Event Queue 、 MacroTask Queue 以及 MicroTask Queue 嗎?因為有了 Queue 這種樣子的資料結構,JS 才能夠具有非同步這麼具有識別度的特性。那麼以程式的角度來看,Queue ㄧ樣是有兩種方法的陣列:unshiftpopunshift 可以把元素放到 Queue 的最尾端,而 pop 則是把元素從最前端取出來,Queue 也可以反向操作,只要把 unshiftpop 換成 shiftpush

https://ithelp.ithome.com.tw/upload/images/20191013/20106580JRDdzjisVa.jpg

Linked List

Linked List ,鏈結串列是一種有序的、且線性的資料結構,在 Linked List 上每一筆資料都可以被看作是一個節點 ( Node ),每個節點上都包含了兩個資訊:一個是要儲存的數值、一個是指向其他節點位置的指標。Linked List 具有以下特性:

  • 是被一個一個指標串連起來的
  • 第一個節點被稱為 head ,是一個指向第一個節點的參考指標
  • 最後一個節點被稱為 tail 節點,是指向最後一個節點的參考指標
  • 最後一個指摽指向的是 null

Linked List 基本上有 單向( singly ) 跟 雙向 ( doubly ) 兩種類型,在單向的鏈結串列中,只存在一個指向下一個節點的指標。

https://ithelp.ithome.com.tw/upload/images/20191013/20106580Jh38FNHdmU.jpg

而在雙向的鏈結串列中,則會有兩個指標,一個指向上一個節點,一個指向下一個節點。

https://ithelp.ithome.com.tw/upload/images/20191013/20106580nZx8X7Mikl.jpg

Linked List 由於結構的關係,可以在頭、尾,任何地方插入節點,因為只要改變指標的指向就可以了,所以只要搞懂運作方式,他也能實現前面提到的 Queue 跟 Stack 結構的行爲。Linked List 在前後端開發上也很有幫助,在前端 React 框架常常搭配使用的狀態管理器 Redux 中,從畫面到 Action 到 Reducer 這樣子的資料流,就使用了 Linked List 的思考方式,來決定資料的下一個目標( 函式 )。在後端 Express 框架上則用 Linked List 來處理 Http Request 與 Middleware 層的資料流動。

接下來讓我們以雙向的鍊結串列來看看實際上在 JS 內是怎麼使用的,首先我們會需要節點的類別,這樣我們就可以自己指定節點跟下一個節點:

class LinkedNode {
		constructor(value,prev,next){
				this.value = value;
        this.next = next;
        this.prev = prev;
		} 
} 
let head = new LinkedNode(null,null,null)
let node1 = new LinkedNode(1,head,null)

let node2 = new LinkedNode(2,node1,null) 
let node3 = new LinkedNode(3,node2,null) 

node1.next = node2
node2.next = node3

然後我們可以再創一個 LinkedList 類別來記錄這些節點間的關係,

class LinkList {
	constructor(value,prev,next){
		this.head = null 
		this.tail = null
		this.lenght = 1
	} 
	addToHead(){
	} 
}

Linked List 要能再頭地方加入新的節點成為新的 head,因此加入輔助函式看看:

class LinkedNode {
		constructor(value,prev,next){
				this.value = value;
        this.next = next;
        this.prev = prev;
		} 
} 

class LinkList {
	constructor(value){
		this.head = null 
		this.tail = null
		this.addToHead(value)
		this.lenght = 0
	} 
	addToHead(value){
		const newNode = new LinkedNode(value);
		newNode.next = this.head; // 讓原本的 head 成為新節點的 next
		newNode.prev = null // head 並沒有前一個節點 
		this.head = newNode // 最後把原來的 head 換成新的節點
		
		this.lenght += 1
		return this.head
	} 
}

let newList = new LinkList('first')
newList.addToHead('second')
newList.addToHead('third') 

newList.head.value // third 
newList.head.next.value  //second
newList.head.next.next.value //first

接下來我們再實作一個可以從中間刪除任意節點的方法,要找到 Linked List 的某一個數值並且刪除,就只能用尋訪的方式一個一個尋找,這裡我們用 while 回圈以一個類似遞迴的方式來尋找:

class LinkList {
	constructor(value){
		this.head = null 
		this.tail = null
		this.addToHead(value)
		this.lenght = 0
	} 
	addToHead(value){ ...	} 
	removeFromHead(){
			if(!this.head.next) this.head.next = null
			const value = this.head.value;
			this.head = this.head.next 
			this.length-- 
			return value
	} 
	remove(val) {
    if(this.length === 0) {
        return undefined;
    }
    
    if (this.head.value === val) {
        this.removeFromHead();
        return this;
    }
    
    let previousNode = this.head;
    let thisNode = previousNode.next;
    
    while(thisNode) { 
				// thisNode 的參考會隨著 while 而不斷的往 next 去尋找
        if(thisNode.value === val) {
            break;
        }
        
        previousNode = thisNode; // 同時也會不斷紀錄前一個節點
        thisNode = thisNode.next;
    }
    
    if (thisNode === null) {
        return undefined;
    }
    
    previousNode.next = thisNode.next; // 一旦成功找到要刪除的節點,才能夠順利銜接前後節點,達到刪除的效果
    this.length--;
    return this;
}
}

示範實做跟說明幾個函式到這邊,基本上只要知道怎麼修改節點的指向,就可以了解怎麼實作這些操作 Linked List 的方法,包括從 head 刪除節點、從中間新增、刪除節點,以及從最後面新增、刪除,讀者可以自己練習完成看看。

Tree

樹的結構跟 Linked List 有點像,也是從一個節點開始往下長,差別在於 Linked List 裡一個節點只能對到另一個節點,而在樹狀結構內,一個節點可以對到好幾個其他節點,也稱為子節點( Child Node ) ,之前我們講到的 DOM ,正是一種樹狀結構,最上層的 html 是上層節點,而往下延伸出 bodyhead 等下層子節點。

而樹的結構也可以被加上特殊的規則,例如常聽見的二元樹結構, 就是從樹狀結構演變而來,因為在二元樹裡面,每個節點被規定只能擁有另外兩個子節點。而且左邊子節點的數值只能小或等於父節點的數值,而右邊子節點的數值必須大於父節點的數值,以這樣子排列方式,我們就可以有規律的去搜尋或是操作我們需要的節點,例如,整個二元樹的最小值可以在最左邊且最後代的子節點被找到,反之在最右邊後代節點則可以找到最大值。

https://ithelp.ithome.com.tw/upload/images/20191013/20106580EJC2fUxHnF.jpg

在樹的搜尋上則有兩種相似的方式:

  1. 深先搜尋 ( Depth-First Traversal, DFT ) :

    把樹想成由最上面開始往下生長的結構,深先搜尋就是從最上面的根節點,往下垂直的搜尋,深先搜尋裡又分為三種走訪順序,以上面的二元樹圖為例,分別是:

    • 前序 ( Pre Oreder ) :

      順序:訪問根節點 → 訪問左子樹 → 訪問右子樹

      上圖順序: A → B → D → G → C → E → F → H

    • 中序 ( In Order ) :

      順序:訪問左子樹 → 訪問根節點 →訪問右子樹

      上圖順序: D → G → B → A → E → C → F → H

    • 後序 ( Post Order ):

      順序:訪問左子樹 → 訪問右子樹 → 訪問根節點

      上圖順序: G → D → B → E → H → F → C → A

  2. 廣先搜尋 Breadth-First Traversal , BFT )

    廣先搜尋則跟深先搜尋相反,是以水平方向為主的搜尋方式,在樹狀結構裡面,每往下長出一個子節點,就會被視為一層。深先搜尋在執行時是先查看節點有無子節點,如果有的話就盡量往下去搜尋,而廣先搜尋則是在搜尋時先檢查子節點有無其他同一層的節點,然後將這些同層子節點記錄下來,一個一個去搜尋,因此在執行廣先搜尋時,必須用到 Queue 來輔助。

    上圖順序: A → B → C → D → E → F → G → H

樹狀結構與前面鍊狀串列結構實作方法相似,而且樹狀結構若要往下探討可以有很多種變形,例如把不同層的節點串在一下之後就會變成複雜的圖 ( Graph ),這些內容多到可以再寫一篇文章,因此在這邊先不提供範例。

Hash Table

雜湊表是根據鍵值 ( Key ) 來查找對應記憶體位置的資料結構。陣列就是一個很類似 Hash Table 的結構,只不過陣列是利用「索引」來查找資料,因此只能是數字。

可以把 Hash Table 想成是建立在陣列上,透過將不同字串轉成對應的陣列索引來查找,而達到比較靈活的鍵值查找,要達到這樣子的效果,我們會需要實作一個 Hash Function ,來把字串轉換成索引。

https://ithelp.ithome.com.tw/upload/images/20191013/20106580GqUPG5Zh4e.jpg

Hash Function 的運作方式大概會是給每個字元對應的可運算數值,當要查找的時候就把字串內所有字元的數值加起來然後給陣列當成索引值,如果加起來的數值太大,陣列沒有那麼多空間,就必需透過另外的規則簡化(如:加完的數值 mod 10 ) 來取得對應、可行的索引。

getCharNum(char){
	return charCodeAt(char)
}
hashFunction (key) {
	let hashCode = 0 	
	key.split('').forEach(char=>{
		hashCode  += getCharNum(char)		
	}) 	
	return hashCode % 10 
} 

上面是一個 hash function 的實作,當然這只是簡化的範例而已,真正應用在現代系統環境的實作邏輯複雜非常多。透過 Hash Table 的運作方式我們可以利用字串來存取對應的數值,有沒有覺得很熟悉,想到什麼?沒錯就是 JS 的物件!從結果來看 JS 的物件非常像是 Hash Table 的結構,不過根據我的調查結果,有一說是這點會根據 JS 引擎的實作而有所差異,有些引擎裡面是透過混合 Linked List 跟 Hash Table 兩種資料結構來實作物件。

總結

終於講完了這些常見的資料結構,看完之後你應該可以發現這些資料結構大概有一半在前面 JS 相關內容都有提到,分別是:

  • Stack :Call Stack
  • Queue :Task Queue
  • Linked Lists :原型鍊
  • Tree : DOM
  • Hash Table : 物件的 鍵 -值 結構

這些部分如果不深入去看這個語言運作方式的話是不會發覺的,這些資料結構也可以應用在許多系統資料的運算。雖然這個章節只能很粗淺的介紹,但我希望讓原本不熟悉資料結構的人,下次再看到類似的東西可以不會那麼害怕,也能夠更冷靜地往下研究原本要鑽研的知識細節。

參考文章

Data Structures in JavaScript

Objects and Hash Tables in Javascript

Basics of Hash Tables

Hash Table

JS 原力覺醒 Day27 - JS 常用 API - Object.assign && Object.defineProperty

今天要講的是是兩個在操作物件時常用到的 JS API ,有時候我們會需要做一些比較進階的操作,例如對物件屬性做一些比較細節的微調;還有複製物件,但是複製物件的話,因為物件傳參考的特性的關係,在結構複雜的物件上,往往需要特別處理,例如物件內的屬性是另外一個物件。所以我們也會帶到「深拷貝」和「淺拷貝」的概念。
https://ithelp.ithome.com.tw/upload/images/20190916/20106580lJIWdcHc2t.png

Outline

  • Object.defineProperty
  • Object.assign
  • 深拷貝
  • 淺拷貝

Object.defineProperty

Object.defineProperty 其實是 Object 函式建構子上的靜態方法(還記得 Obejct 其實是一個函式?),用來對某個物件直接定義一個新的屬性,用法如下:

const object1 = {};

Object.defineProperty(object1, 'property1', {
  value: 42,
  writable: false
}); 

這個方法接受三個參數,第一個是要新增屬性的目標物件,第二個是屬性名稱,第三個是這個屬性的描述器設定。 屬性的描述器?那是什麼?

JS 內物件屬性的描述器有兩種類型,每一種各有不同設定值:

  • 資料描述器 ( Data descriptor ):

    資料描述器是一個帶有值的屬性,其實也就是你要定義屬性的 value 啦。這個屬性有可能是可修改、或是不可被修改的。

  • 存取描述器 ( Accessor descriptor ):

    存取描述器定義的內容包含的 gettersetter 兩個函式。要怎麼存、取這個屬性,就是由存取描述器來負責的。

兩種描述器都有屬於自己的屬性設定值,先分別介紹:

  1. 資料描述器上,有兩個可選值:
  • value ( undefined ) : 定義這個屬性對應的值。
  • writable ( false ): 定義這個屬性是某可以被指派,如果為 true 就代表這個屬性可以透過 如 ob.name= 'new value' 被更新。
  1. 存取描述器上也有兩個可選值:
  • get ( undefined ) : 即物件的 getter 函式,是一個定義物件如何被取用的函式,當物件屬性被取用的時候會被呼叫。
  • set ( undefined ) : 即物件的 setter 函式,是一個定義物件如何被指派的函式。

剩下的幾個設定值是兩種描述器都能夠使用且可選、非必須的。分別是( 括號內的是預設值 ):

  • configurable ( false ) : 定義了這個物件屬性的描述器設定是否可以被修改,如enumerablewritable 、 或是自己本身 configurable
  • enumerable ( false ) : 定義這個屬性在物件裡就屬於可以被巡訪的,也就是使用 Object.keys 或是 for...in 來對物件作遍歷的時候能不能夠存取到。

而要定義的物件屬性的描述器必須一定要是上述兩者中的其中一種,兩者無法同時屬於兩者。

Example - 描述器預設值

var o = {}; // 創造新物件 

Object.defineProperty(o, 'a', {}); // empty descriptor setting

Object.getOwnPropertyDescriptor(o,'a')  
//預設描述器值:
// configurable : false 
// value : undefined 
// writable : false 
// enumable : false 

剛剛說描述器無法同時是資料描述器跟存取描述器,也就是說在 ****defineProperty 的第三個參數描述器設定內,如果有 get 這個設定值出現,就不能再有 value ,否則就會報錯:

var o = {}; // 創造新物件 

Object.defineProperty(o, 'a', {
  value: 37,
  writable: true,
  enumerable: true,
  configurable: true,
	get(){
		return 123
	}
});

//Invalid property descriptor. Cannot both specify accessors and a value or writable attribute

Example - 自訂 gettersetter 函式

自訂 gettersetter 一樣是在 Object.defineProperty 裡面的第三個參數自訂屬性的行為:

var o = {}; 
Object.defineProperty(o, 'a', {
		get() {
        return 'It will return this value if I access o.a' ;
    },
    set() {
        this.myname = 'this is my name string';
    }
});

Object.assign

Object.assign 用來複製所有物件內可被尋訪 (Enumable) 的屬性,而且複製的來源不限於某個物件,可以多個物件一起進行屬性的複製,這個方法的第一個參數跟 defineProperty ㄧ樣都是目標物件,後面可以有複數個參數,就是要被複製屬性的來源。而使用 Object.assign 來進行複製的時候,後面的相同物件屬性會蓋掉前面相同的物件屬性:

let b = Object.assign({foo: 0}, {foo: 1}, {foo: 2});
ChromeSamples.log(b)
// {foo: 2}

所以,如果我想要複製某一物件的內容到一個全新的物件上的話,只要這麼寫:

let oldObject = {
	a:'a', 
	b:{
		c:'cinsideb'
	}
} 

let newObject = Object.assign({},oldObject)

console.log(newObject) //{a: "a", b: {…}}

另外,如果只是單純要把某個物件內容複製到另外一個物件,可以用 ES6 後的新的、比較簡潔好閱讀的寫法 Spread ,也可以達到一樣的效果:

let newObject = { ...oldObject }

淺拷貝 ( Shallow Copy )

在使用 Object.assign 時有一個要注意的地方,就是他雖然可以複製屬性,但要是物件屬性的內容也是另外一個物件時,從這個屬性複製到新物件上的,也只會是這個內層物件的參考,而不是這個物件的拷貝,這個現象就稱為淺拷貝(可以理解為,只複製最外層屬性,往下被複製的都只有參考)。

let oldObject = {
	a:'a', 
	b:{
		c:'c'
	}
} 

let newObject = Object.assign({},oldObject)

newObject.b.c = 'modified c' 

console.log(oldObject) 
/* {
	a:'a', 
	b:{
		c:'modified c'
	}
} */

由上就可以看出,當我修改新的物件的內層屬性物件時,被複製的物件的內層屬性物件 (b.c),也會跟著一起被改動。

深拷貝 ( Deep Clone )

相對於淺拷貝,深拷貝就是完全的複製整個物件內容了。那麼如果要達到這個效果,我們可能要自己動手處理,檢查要複製物件的某屬性是不是物件,如果是的話,就要再以Object.assgn 複製一次,並且這個檢查要搭配遞迴的概念來檢查,才能確保完全的複製。

function cloneDeep(obj){
            if( typeof obj !== 'object'  ){
                return obj
            }
            let resultData = {}
            return recursion(obj, resultData)
        }

function recursion(obj, data={}){
						//對物件屬性做巡訪
            for(key in obj){
                if( typeof obj[key] === 'object'){
										// 如果是物件就繼續往下遞迴
                    data[key] = recursion(obj[key])
                }else{
										// 如果不是物件的話就直接指派
                    data[key] = obj[key]
                }
            }
            return data
        }
let player = {name:'Anakin',friend:{robot:'R2D2'}}
let player2 = cloneDeep(player)
obj.name = 'Darth Vader!!!'
player2.friend.robot = 'no!!!'
console.log(player) // {name:'Anakin猿',friend:{robot:'R2D2'}}

參考文件

MDN 官方文件的說明

Javascript properties are enumerable, writable and configurable

JS-淺拷貝(Shallow Copy) VS 深拷貝(Deep Copy)

JS 原力覺醒 Day26 - 常用 API: setTimeout / setTimeInterval

來講一下常用到的瀏覽器 API ,其實前面在講 Event Queue 的時候就已經提過 setTimeout 了,不過這邊就讓我們從更具實用性的層面來看這些方法。
https://ithelp.ithome.com.tw/upload/images/20190916/20106580lJIWdcHc2t.png

Outline

  • setTimeout / setInterval 使用
  • setTimeout / setInterval 清除
  • this in setTimeout / setInterval callback
  • 解決回呼函式內 this 的問題
  • setTimeout / setInterval 、迴圈與 Closure

setTimeout / setInterval 使用

setTimeout

前面有提到 setTimeout 的基本使用方式,而第一個參數傳入的 callback 會被推送到 Event Queue ,待主執行環境堆疊清空以後,才會被執行 ,所以就算第二個參數設定的時間是 0 秒,也不會立刻執行。

function step(stepNum){
	console.log(`step${stepNum}`)
}

step('1') 
setTimeout(function(){step('2')},0)
step('3')

// will print: step1 --> step3 --> step2

setInterval

setInterval 使用方式與 setTimeout 的語法相同,差在 setTimeout 只會執行一次,而 setInterval 則會根據開發者給的時間間隔,每隔一段時間執行一次。

 setInterval(function(){console.log('da')  },1000)
// print : da -> da -> da

setTimeout / setInterval 清除

由於 setTimeout / setInterval 函式本身會回傳一個計時器 id ,我們就可以把這個 id 記錄下來,當頁面要離開用不到的時候使用 clearTimeout / clearInterval 將他們清除:

let timerId = setInterval(function(){
	console.log('do something') 
},1000) 

清除計時器在以前可能還不是會非常被注重的問題,但是像現在主流前端框架把渲染工作交給 JS ,如果使用虛擬路由來控制頁面切換的話,就算頁面切換了,JS 檔案也不會重新載入,主執行環境會一直存在,因此前面設定的計時器在不需要時如果沒有清除,就可能會造成頁面運算的負擔。

this in setTimeout / setInterval callback

setTimeout / setInterval 裡第一個回呼函式內的 this 如果沒有經過處理的話,預設都是指向全域環境 window ,因為這兩個都是屬於 window 物件底下的函式,我們可以推斷我們傳進去的回呼函式是在裡面被執行。雖然沒辦法直接看到 setTimeout 裡面的原始碼,不過可以推斷內容大概是像這樣 ,下面以 Pseudo code 示意:

 window  = {
		... 
		setTimeout:function(timerFunc,time){
			//several minutes later...
			timerFunc() 
		} 
}

之前提過在思考 this 的連結的時候,有提到,「如何呼叫」函式將會影響 this 的指向,想一想「隱含」的繫結, 再對比上面的 setTimeout 內容,可以看出我們傳入的回呼函式在 setTimeout 被呼叫,但因為是直接呼叫,沒有隱含繫結,因此在內的 this 會指向全域。

解決回呼函式內 this 的指向問題

承上一段,那要怎麼樣才能讓 this 指向目前所屬的執行環境,讓開發者在撰寫程式碼的時候更不容易誤解?

方法一

有一個方法是:使用箭頭函式,因為箭頭函式內沒有 this ,更準確來說, 箭頭函式內的 this 與他外部語彙範疇的 this 相等。

let boss = 'Yoda'
let user = {
	name:'Luke',
	introduce:function(){
		setTimeout(()=>{
			console.log('hey, ' + this.name) 
		},1000) 
	} 
} 
user.introduce() // print : hey,Luke

方法二

另外一個方法是,使用 Function.bind,這個方法跟 callapply 都可以指定函式執行環境內要綁定的 this ,差別在呼叫 bind 後會回傳一個全新、綁定過 this 的函式。

let user = {
	name:'Luke',
	introduce:function(){
		setTimeout(getName.bind(this),1000) 
	} 
} 

function getName(){
	console.log('hey, '+ this.name)	
} 

user.introduce() // print : hey,Luke

setTimeout / setInterval 、迴圈與 Closure

這段要講的大概是最經典的面試考題,只要講到跟 Closure 有關的問題,通常一定會提到迴圈。
先來看這段例子:

for(var i =0;i<10;i++){
	setTimeout(
		function (){
			console.log(i)
	},1000) 
}

在一秒過後我們就很驚訝的會發現, JS 吐出了 10 個 10 給我們,這是因為 var 宣告是屬於 function scope 但是 for 迴圈並不是 function ,所以在之內宣告的變數 i 就等於是全域變數。也因此無法透過 fucntion 產生函式執行堆疊或閉包,於是這個回呼函式會被推到 Event Queue,待時間到要執行,去獲取i 的時候,全域的 i 早就已經被 for 迴圈修改而成為10了 ,所以才會有這樣子的結果

解方:

要解決這個問題我們只要想辦法讓維持 setTimeout 回呼函式與每個 i 的聯繫即可,還記得 let 屬於 block scope ?所以用 let 產生的變數是綁在會在不同的 block 上 ,對 for 回圈來說,每次 i+1 的迴圈迭代之後的,都是一個新的 block,再搭配 block scope 的特性,就可以在每個 block 留下與每個 i 的連結:

for(let i =0;i<10;i++){
	setTimeout(
		function (){
			console.log(i)
	},1000) 
}

// print : 1,2....10

或是ㄧ樣利用 function scope 的特性:

for(var i=0;i < 10; i++){
	getValueOf_i(i)
}

function getValueOf_i(i){
	setTimeout(function(){
		console.log(i)
	},1000) 
} 

這樣一來當 i 以參數形式傳入另外一個函式時,就會被函式執行環境保留而產生閉包。

JS 原力覺醒 Day25 - CRP : 關鍵渲染路徑

當使用者進入頁面、瀏覽器收到請求並回傳前端相關檔案後,到最後使用者看到的畫面呈現之前,還有很多步驟會被執行,這一連串步驟的總和就稱為 Critical Rendering Path ( 中譯:關鍵渲染路徑),了解關鍵渲染路徑,在網站前端頁面需要做效能優化時,就可以比較容易知道,要從哪裡下手。
https://ithelp.ithome.com.tw/upload/images/20190916/20106580lJIWdcHc2t.png

關鍵渲染路徑(以下簡稱 CRP ) 大致上會執行以下六個步驟:

  1. 建構 DOM Tree
  2. 建構 CSSOM Tree
  3. 執行 JavaScript
  4. 創造渲染樹
  5. 產生畫面佈局
  6. 繪製、產生畫面

下面就讓我們一個步驟一個步驟詳細來看:

Step1. 建構 DOM Tree

前一章節有講到網頁的 DOM 是根據 HTML 內容而來,這個轉換的過程有點像這個系列一開頭我們討論 JS 語法解析那段,瀏覽器會根據 HTML Tag 將內容轉為一個一個 Token (標記)

https://ithelp.ithome.com.tw/upload/images/20191010/201065802PnTTlOshi.jpg

之後會根據這些 Token 將對應的標籤轉換成節點,之後根據 Token 的前後關係產生出 DOM Tree 。

https://ithelp.ithome.com.tw/upload/images/20191010/20106580peW9V1tTe4.jpg

Step2. 建構 CSSOM Tree

CSSOM ( CSS Object Model ) 是代表跟 DOM 元素對應樣式的物件。他的表現形式跟 DOM 很像,只是 CSSOM 是依附著每個節點,各個節點都會有對應的樣式 ( Style ),所以基本上 CSSOM Tree 跟 DOM Tree 長的會很像。

https://ithelp.ithome.com.tw/upload/images/20191010/20106580iAbwkUacyT.jpg

這邊要注意的是,CSS 在頁面載入行為裡,是屬於鎖定渲染的資源( Render Blocking Resource ),意思是,在頁面仔入時,只要還沒有拿到所有的 CSS 檔案並成功載入,那瀏覽器就會等到完成載入為止,這意味著,每個網頁上的 CSS 檔案,都會拖到載入速度。

除了 Render Blocking ,也有人說 CSS 是 「Script Blocking 」,因為在瀏覽器載入所有的 CSS 檔案之後,瀏覽器才會進入的我們的下一步「執行 JS」。 在產生 CSSOM 時,越多層的選擇器,在元素與樣式的匹配上會需要更多時間來進行。以下面這兩個 CSS 類別為例:

A : p {  color:red; } 

B : div h1 { font-size:22px;}

第二種 B 情況的 CSS 會需要更多時間來做匹配,首先瀏覽器會先找到頁面上所有的 h1 元素,而後在看這個元素的父類別是不是一個 div 元素 ,因此瀏覽器在匹配樣式時其實是以「從右邊到左邊」的順序來進行的,所以現在你就了解,如果你有加速前端渲染速度的請求,就要減少 CSS 選擇器層級的長度,在這方面,BEM 的 CSS 命名撰寫風格就把層級關係透過命名的方式來表達,同時也大幅度的減少選擇器的少用次數,建議對 CSS 有興趣鑽研的人一定要看一下。

類似的命名風格或規範,除了 BEM 之外還有 OOCSS 跟 SMACSS ,這些規範都是透過一些原則,來達到最大程度的減少重複,除了好維護之外,其實也能提升畫面渲染的效率,這也是為什麼這些規範常常被資深前端人員提起、並視為圭臬的原因。

Step3. 執行 JS

JS 則是鎖定轉譯,在 JS 執行完之前,瀏覽器都不會繼續做 HTML 文件的轉譯跟建構。當瀏覽器轉譯時碰到 <script> ,他會停下來等到 JS 執行完成之後才會再往下。這也是為什麼我們常常說要把 <script> 標籤放到整個網頁最後面的原因。

Step4. 創造渲染樹

渲染樹 (Rendering Tree) 其實就是 DOM 搭配 CSSOM 的結果,在用白話一點的方式來說,就是「最後會被渲染在畫面上」的結構樹,所以如果 CSS 樣式導致某個 Node 沒辦法顯示,( 如display:none ),那麼他就不會出現在渲染樹上。

Step.5 產生畫面編排 ( Layout )

我們已經取得代表元素層級關係的 DOM 樹結構,也匹配了個元素對應的樣式,最終搭配兩者產生出了渲染樹,現在我們離最後產生可視畫面的階段已經不遠了,但是還差一個步驟,我們還必須弄清楚所有元素的實際位置,以及元素該如何呈現,那就是產生畫面編排 ( Layout ) 的步驟。

Layout 產生的方式,會跟 meta tag 裡面的 viewpoint 屬性有很大的關係:

<meta name="viewport" content="width=device-width, initial-scale=1.0">

viewpointmeta tag 用來告訴瀏覽器,頁面要怎麼縮放,還有維度,就是指畫面像素(瀏覽器畫面)跟螢幕像素(硬體)的比例,content 之中,width 用來設定瀏覽器畫面寬度是多少,把他設定成 device-width 的話就是在告訴瀏覽器,畫面顯示的螢幕寬度要跟硬體裝置相同(手機、電腦),如果沒給 width 值的話,瀏覽器就會使用預設的 980px 來當作預設的畫面顯示寬度。這個屬性在 HTML5 後出現,常用在 RWD 的設計實作之中。 initial-scale=1.0 是指預設的縮放程度,最常見的值也是預設值,就是 1

Step6. 繪製、產生畫面

到最後一個步驟,瀏覽器進入到了繪製階段,前面提到一連串很抽象的設定跟結構,終於可以被轉換成一個個像素,繪製階段所花的時間會跟 DOM 結構樹 與 CSSOM 樹的大小、規模有關,越複雜的結構或是樣式就會需要更多時間,應該不難理解。

從開發者工具看渲染順序

我們透過瀏覽器的檢查工具,也能看出上面講的 CRP 六個步驟,是不是真的依照順序進行,以 Chrome 為例子,打開開發者工具,並切換到 Performance 之後,按下錄影,重新整理之後結束錄影,就能夠看到這段時間內瀏覽器是怎麼產生畫面的:

https://ithelp.ithome.com.tw/upload/images/20191010/20106580UtQhk3zM67.jpg

對應前面步驟說明:

1、2: 拉取資源並解析 DOM 樹

  1. 為 index.css 解析 CSSOM 樹

  2. 執行 JavaScript 檔案 ( index.js )

  3. 根據 viewpoint 的 meta tag 產生layout

  4. 繪製螢幕

參考資源

Google 在 Udacity 的教學真的講的蠻仔細的,搭配圖文也能更讓學習者一目瞭然:

JS 原力覺醒 Day24 - DOM

今天要講的是瀏覽器的 DOM 的概念,內容雖然跟 JS 語言比較沒有關係,但是除非你只寫後端 node.js ,否則只要跟介面相關一定會碰到需要處理 DOM 元素的情況出現,今天就讓我們學著好好跟 DOM 相處。

Outline

  • DOM 是什麼?
  • 畫面是如何透過 DOM 被產生的?
  • DOM Tree
  • 與 DOM 互動
  • 總結

DOM 是什麼?

在我們漫長的前端職涯中,每位前端開發者心中都一定曾經出現過、或是被問過這個問題,那就是到底什麼什麼是 DOM 呢?我們都知道 HTML 是透過標籤式的語法來描述網頁中元素與元素的關係,一對標籤通常就代表一個元素,而且標籤又可以放在另外一個標籤之內,因此元素之間是會有上下層級的,而 DOM 呢,就是透過把這樣子的層級結構轉換為對應的「物件」而成的關係模型

DOM 並不是只能透過 HTML 產生,其他類似的語法像是 SVG、XML 這裡的物件並不一定要是 JS 的物件,因為,但是在瀏覽器裡面,是的,這裡我們討論的物件就是 JS 裡的物件,例如我們在操作 DOM 元素時最常用到的 document 物件。

 document.createElement('div')

所以,我認為DOM 是:

將HTML文本的複雜層級關係,轉換成以物件結構的方式來表現 ,讓程式語言得以與之溝通。

https://ithelp.ithome.com.tw/upload/images/20191009/201065801mBHZYtbqQ.jpg

畫面是如何透過 DOM 被產生的?

因為 HTML 語法大部分都是是成雙成對且有層級關係的標籤,而在使用者透過瀏覽器進入網頁,瀏覽器開始讀取 html 檔案,就會開始把開發者寫的 HTML 程式碼(即指 Document ) 內容轉成對應的層級關係結構,所以這種結構才會被稱為 Document Object Model (文件-物件模型)。從使用者進入網頁,到顯示最後使用者的畫面之前,會經歷許多步驟,不過大致上可分為兩個階段:

  • 第一階段:瀏覽器會先讀取 HTML 程式碼,並決定最後要渲染在網頁上的內容
  • 第二階段:瀏覽器實際開始渲染,形成最後看得見的畫面

第一階段執行玩後的結果稱為「渲染樹 ( Render Tree )」,渲染樹就是用來表現會被渲染到最終畫面上的 HTML 元素,還有他們的關係與 CSS 樣式,要完成渲染樹,瀏覽器會需要兩樣東西:

  1. DOM :用來表現 HTML 元素的層級關係
  2. CSSOM: 整個網頁內HTML 元素對應樣式的關聯

DOM Tree

DOM 裡面用來表現元素層級關係的物件又稱為「 節點樹 ( Node Tree) 」,他會有這樣子的名稱是因為結構都是從最上層的某個元素,例如 <body> ,往下慢慢延伸、長出許多的分支,整個結構就像是樹一樣,透過這樣子的關係表現形式,程式語言(JS) 與畫面表現 (HTML) ,才得以互相溝通。以下面這個 html 內容為例:

<html>
<head>
  <title>DOM Example</title>
</head>
<body>
		<h1>	It's All About DOM </h1>
		<p>Hello World!!</p>
</body>
</html>

https://ithelp.ithome.com.tw/upload/images/20191009/20106580zqwZaavHgD.jpg

HTML 中,在另外一個元素標籤裡面的元素就是該元素的子元素,如 <html> 元素在最上層,所以其他包含在這個元素裡面的都是他的子元素,而這些元素內又會有其他包含的元素,如此重複、不斷往下堆疊,**而把每個元素都看成一個節點的話,就會形成 DOM 的結構樹 ( DOM Tree) 。而每個樹的節點在也都對應為一個物件,**如此一來 JS 才能透過 瀏覽器的 API 如 document. querySelector 跟每個元素互動或溝通。

與 DOM 互動

透過 JS 我們可以跟 DOM 互動來改變畫面的呈現,或是新增一些互動的功能,像是:

  • 改變或刪除 DOM 元素
  • 修改元素的 CSS 樣式
  • 讀取及修改 DOM 元素上的屬性 ( id 、 class 、 src 這些標記性的內容)
  • 創造新的 HTML 元素到 DOM 裡面
  • 在 DOM 元素上新增監聽事件(如:點擊)

## 總結

今天我們了解了什麼是 DOM ,DOM 是從開發者寫的 HTML 程式碼轉換而來,但 HTML 語法本身並不是 DOM ,而瀏覽器就是因為透過 DOM ,才能讓 JS 跟畫面的元素溝通。下一篇,我會講解從使用者進入畫面後,瀏覽器是怎麼從生成 DOM ,然後透過一連串的處理,最後才顯示畫面的。

參考資源

JavaScript DOM Tutorial #1 - Introduction

MDN官方說明

DOM Nodes

JS 原力覺醒 Day23 - Class 語法糖

講完了原型鍊,現在我們知道如何透過建構函式去做到類似類別的效果,也透過設定物件的 prototype 屬性達到物件的繼承效果, ES6 之後,甚至出現了 class 關鍵字,讓我們可以用更物件導向的方式去撰寫 JS。
https://ithelp.ithome.com.tw/upload/images/20190916/20106580lJIWdcHc2t.png

Outline

  • Class 基本用法
  • class 宣告式的防呆機制
  • 透過 class 宣告來達成類別繼承
  • 原型物件方法
  • static 靜態方法
  • 類別建構子內的 super

Class 基本用法

原本我們必須要透過建構函式來來模擬類別產生物件,但是因為函式子實在太像是函式了,所以很容易被搞混。在 ES6 後出現了 class 宣告的方式,讓相關功能的程式碼整體變得更物件導向且直觀、更好閱讀許多。使用 class 宣告類別的寫法會要使用比較多一點語法,但與建構函式不會相差太多:

  • 建構函式:
    function User(name){
    	this.name = name 
    }  
    let user1 = new User(name) 
    
    User.prototype.getName = function (){
    	return this.name
    } 
  • class 宣告式
    class User{
    	constructor (name){
    		this.name = name 
    	}
    	getName(){
    		return this.name
    	}   
    } 

可以看出使用了 class 宣告以後,原本建構函式的內容還是一樣,只是被移動到 constructor 函式內而已。而原本我們要取用 prototype 才能達成方法的共享,現在也只要直接在 class 內直接宣告就可以了( 是不是真的乾淨很多 ),注意在 class 內的方法宣吿方式跟一般物件屬性的宣告不太一樣,那是 ES6 後出現、用來宣告函式屬性的縮寫,且方法與方法之間不需要以逗號相隔。

class 宣告式的防呆機制

為什麼前面說使用函式宣告式很容易讓開發者把他跟一般函式搞混呢?因為使用 new 運算子搭配函式來創造實體 ( instance ) 的時候,基本上也是一種函式呼叫,而且就算沒有加上 new 運算子,函式呼叫還是有效, JS 不會有提示**,**因此就算真的寫錯了也不容易找到錯誤。而使用 class 來宣告的時候,則只有在使用 new 呼叫的時候,才會有效。

透過建構函式來達成類別繼承

還記得前面提過,想要用建構函式來達成繼承的話,有幾個步驟我們必須自己進行:

  1. 建構函式的繼承:

    為了繼承「前代」建構函式的內容,所以我們必須自己在「後代」建構函式內呼叫前代建構函式 :

        function Human(race){
        		this.race = race
        }
        
        function User(name,race){
        	this.name = name 
        	Human.call(this,race)
        } 
  1. 原型物件的繼承

修改「後代」建構函式的原型物件使原本存在其中的 proto,屬性從參考 Object 改為參考到前代物件,然後再把原型物件內的函式建構子指回「後代」建構函式,完成原型鍊的串接:

    let User.prototype = Object.create(Human.prototype) 
    User.prototype.constructor = Human

透過 class 宣告來達成類別繼承

class 是 ES6 後出現的語法糖,語法糖簡化了整個類別宣告的過程,透過 class 宣告類別,讓這一切複雜的設定都變得簡單許多!我們不需要再去修改原型物件,也能直接完成繼承的效果了。使用 class 來實現繼承,會需要搭配另外一個關鍵字 extends ,步驟如下:

  1. 創造要被繼承的類別 Human
    class Human{
    	constructor (race){
    		this.race = race 
    	}
    	getRace(){
    		return this.race
    	}   
    } 
  1. 創造後代類別 User ,並搭配 extends 指向 Human ,代表 User 繼承 Human
    class User extends Human{
    			constructor (name, race) {
            // invoke our parent constructor function. 
            super(race);
    				this.name = name
          }
    } 

類別建構子constructor 的內容就是原本建構函式的內容;而還記得前面有提到我們必須自己在「後代」建構函式內呼叫「前代」建構函式嗎?現在也不需要這麼麻煩, constructor 內的 super 函式就代表了 被 extendsHuman 建構函式,所以我只要直接呼叫 super 就可以了。

原型物件方法

使用建構函式,我們可以在原型物件上新增共享的方法,在 class 宣告中當然也做得到,其實就是在 constructor 外定義的方法,其實前面已經有提過了:

    class Human{
    	constructor (race){
    		this.race = race 
    	}
    	getRace(){ // will be set on the prototype object
    		return this.race
    	}   
    } 

static 靜態方法

靜態方法是物件導向裡面的概念。靜態方法只能由類別本身取得的方法,在產生出來的實例 ( instance )中是無法取得的。staticclass ㄧ樣是語法糖,使用 static 關鍵字定義的方法,會直接被指派到該 class 上,所以就只能從該類別上直接取得,像是這樣:

    class User {
    	constructor(name){
    		this.name = name
    	}
    	static getUserType (){
    		return 'technical'
    	}
    } 
    
    User.getUsertype() //'techical'

對應前面的建構函式,就有一點像是這樣:

    function User (name){
    	this.name = name
    }
    User.getUserType  = function(){
    		return 'technical'
    }

如果從建構函式來看靜態方法的話可能會稍微有一點奇怪,不過畢竟函式本身也是物件嘛,要在之上新增屬性本來就是合法的。

類別建構子內的 super

剛剛說到類別建構子與建構函式內容相同,而裡面的 super 又代表了被繼承類別(或稱前代類別),所以在「後代」類別建構子內一定要呼叫 super 才能有效完成屬性繼承,而在 class 內定義的其他方法則會被定義到原型物件內,所以如果想要取得「前代」建構函式原型物件內的函式,可以直接用 super 來取用,以前面 Human 類別為例子,在 User 類別內就可以這樣做:

    class User {
    	constructor(name){
    		super() 
    		this.name = name
    	} 
    	getRace(){
    		return super.getRace()
    	}
    } 

總結

在我們了解了 JS 內,原型的運作方式之後,我們利用原型達成了繼承的效果,了解了什麼是原型鍊,之後在今天的這篇文章裡面我們又結合了上述提到的所有知識了解了 class 語法糖的使用方式,還有跟舊版建構函式寫法的對應。儘管一切很複雜,相信讀到這裡的你一定有不少收穫。

雖然快結束了,不過如果你對我寫的系列文有興趣,歡迎訂閱,已經訂閱我的人,也非常感謝你們,你們的閱讀就是我寫下去的最大動力,希望我可以把 30 天都撐完!

JS 原力覺醒 Day22 - 原型共享與原型繼承

前一天我們提到 JS 的原型,以及為什麼會有原型的出現 :為了模擬物件導向的行為。 那麼原型實際上帶來什麼好處?又是透過什麼方式達到繼承的目的?

Outline

  • 原型共享:原型的運作方式
  • 原型鍊
  • 原型繼承

原型共享:原型的運作方式

__proto__ 屬性會在物件產生的時候被加到這個物件上,這個 __proto__ 就是透過參考的方式,將「被生成物件」與函式的「原型物件」做連結 ( 看到 proto 前後的「_」有沒有把他跟「連結」做聯想,是不是覺得這個變數取的很好? )。這個自動產生 __proto__ 參考的行為是 JS 預設的動作,有一點像是這樣:

let user1 = new User() 
user1.__proto__ = User.prototype 

當然因為這件事情是自動發生的,所以我們不需要手動去做這件事情,在開發上也不建議操作 __proro__ 這個變數,請讓他自由,所以整理一下提到的兩個名詞。

  1. proto :會在物件被生成時一起被指派到物件上的屬性,他決定這個物件的原型物件是誰。
  2. prototype :會一直存在於建構函式上的屬性,所有透過該函式產生的物件都有能力存取。

當我們想要取用物件中的某個屬性時,JS 會先去物件中尋找該屬性,如果沒有,就會轉而透過__proto__ 往原型物件屬性,也就是 prototype 原型物件,去尋找這個屬性。由於原型物件 prototype 本身也是物件,所以我們在這個物件內也可以另外新增屬性,而透過前面的說明我們也可以知道原型物件是在被生成物件之間被共享的,所以我們就可以把一些共用的變數或是方法,放到這個共用的物件之內。

let defaultName = 'Darth Vader'
User.prototype.name = defaultName

let user1  = new User() 
let user2  = new User() 

user1.name // 'Darth Vader'
user2.name // 'Darth Vader'

這麼做有什麼好處? 把共用函式放在函式建構子裡面的話,每個被生成物件還是會有一樣的函式阿?是這樣沒錯,但是這樣等於是把同樣的數值或函式複製好幾次,生成幾個物件,JS 就會需要幾個記憶體空間;而要同樣的目的,其實只要放在 prototype 原型物件內就可以用較低成本的方式達成。

原型鍊

剛剛說到當 JS 引擎在物件內找不到某個屬性時會透過 __proto__ 去往 prototype 原型物件去搜尋這個屬性,如果原型物件裡還是找不到,這個原型物件上也還會有一個 __proto__,指向他所屬前代類別的原型物件,例如 JS 內 Array 其實也是物件,所以可以說他的前代就是 Object 物件:

Array.prototype.__proto__ === Object.prototype // true

因此 JS 引擎會再透過原型物件裡的 __proto__ 屬性往上一個原型物件尋找,直到真的找不到為止 ( 會找到 JS 內 Object 物件的原型物件為止,你可以再透過 __proto__ 往上找找,最後會發現他是 null )。這個行為跟當初我們講到範圍鍊 ( Scope Chain ) 的行為類似,所以也稱為「原型鍊 ( Prototype Chain )」。

https://ithelp.ithome.com.tw/upload/images/20191007/20106580PmyBG9ONck.jpg

原型繼承

最後這個部分就讓我實際的程式碼範例來實作繼承,順便藉此說明原型鍊概念的實用性,在繼承的行為裡,透過被繼承的「後代類別」,所產生出來的物件,一開始就應該要直接具有「前代類別」的屬性跟方法,我們來嘗試看看有沒有辦法透過 JS 達到這個目的。

現在假設:

  • 我們有一個 Human 類別跟 User 類別
  • 在 Human 類別的物件上有一個 getRace 方法
  • 在 User 類別的物件上有一個 getUserName 方法

我們的目標是:透過原型鍊實現 Human 與 User 兩者的繼承關係。

function Human (action, height,race){
	this.action = action 
	this.height = height 
	this.race = race
}


function User(fisrtname,lastname){
	this.fisrtname = fisrtname 
	this.lastname = lastname 
}
 

Human.prototype.getHumanRace = function(){
	return this.race
} 

User.prototype.getFullName = function(){
	return this.firstname + this.lastname
}

在正式開始之前我們要先思考一下有哪些部分要處理,才能夠讓要繼承的函式建構子與被繼承的函式建構子共享屬性跟方法,主要有兩個方向:

1. 前後代類別原型物件繼承

因為透過 new 運算子生成物件的時候,這兩個建構函式上都會各有一個 protorype 物件,一般情況下他們各自為政 ,但是在處理繼承的時候我們必須同時考慮兩者之間的連結。

前面提到物件在找不到屬性時,就會往原型物件找,如果原型物件裡還是找不到,就會再透過原型物件裡的 __proto__ 屬性往上一個原型物件尋找,形成原型鍊。原型物件之間要做到繼承就代表了:

透過「後代類別」產生的物件,其上有屬性不管在物件內還是在原型物件上都無法找到時,會轉而往「前代類別」的原型物件尋找

能夠做到這樣子的行為,我們才能說我們透過建立原型屬性的原型鍊,而做到繼承的效果。為了達到這樣子的效果,很顯見的我們必須修改物件上的 __proto__ 連結,但是前面也有提過再開發上不建議直接修改__proto__ 的參考,因為會破壞物件的預設行為,儘管如此,我們還是可以用比較曖昧的方式來修改這個連結:

User.prototype = Object.create(Human.prototype)

我只用一個之前沒看過的 JS 內建方法 Object.create 修改了繼承物件 User 的 prototypeObject.create 可以用來創在一個全新的物件,而且他把第一個參數傳入的物件拿來當作這個新物件的 prototype ,之後我們就可以發現 User 的原型物件,被我們修改成一個新的空物件,而這個物件的原型,正是指向 Human , 透過這樣的方式 ,我們就把兩者之間繼承的原型鍊串起來了。

https://ithelp.ithome.com.tw/upload/images/20191007/20106580jQaA3vn2o0.png

但是如果你有注意到的話,原本在原型物件上都會有個指回建構函式的prototype.constructor 已經不見了,因此我們需要手動把他加回來,JS 才能夠查找到正確的建構函式。

User.prototype.constructor = User

https://ithelp.ithome.com.tw/upload/images/20191007/20106580jaRrWOjwPH.jpg

2. 前後代建構函式內容繼承

透過原型物件確實可以達成共享,但如果透過這個方法來共享某些特定屬性,因為屬性的記憶體空間只有一個,這麼一來如果是像「姓名」、「年齡」這種每個人(實體)都會有不同數值的資料,就不適合放在原型物件內,所以我們要想辦法讓我們在「後代」建構函式內可以直接取得「前代」建構函式內容。

簡單來說就是讓前代類別的內容出現在透過後代類別的建構函式所產生的物件上,這裡有一個很經典的辦法,那就是在後代 ( 繼承類別 ) 建構函式裡面執行前代( 被繼承類別 ) 建構函式:

function Human (height,race){
	this.height = height 
	this.race = race
}


function User(fisrtname,lastname,race,height){
	this.fisrtname = fisrtname 
	this.lastname = lastname 
  Human(height,race) // This is not totally correct
}

這麼一來當 User 透過 new 被呼叫的時候,除了會將 User 內的 this 繫結綁到新生成物件上,還會有另外一個充滿使用 this 繫結來設定物件屬性的 Human 方法被執行,如此一來,前代類別的屬性設置就能夠與後代共用,而前兩行定義的 firstnamelastname ,也正好是 User 專屬,Human 不會有的資料屬性,當然我們也可以直接把 Human內定義的屬性搬到 User 內,不過這樣就會變成是重新定義一整個物件屬性,就失去繼承的意義了:

// dont do this if you want to make an inheritance.
//THIS IS AN ANTI-PATERN
function User(fisrtname,lastname,race,height){
	this.fisrtname = fisrtname 
	this.lastname = lastname 
	this.height = height 
	this.race = race
}

但是還沒有完,這邊有一個前面提過很重要的觀念,那就是當我們 執行 Human 方法時,裡面的 this 繫結並非透過 new 被觸發,所以並不是指向剛剛透過 User 函式建構子被生成的新物件,這個時候我們要透過「明確的繫結」來修改 this 的指向,來把 User 內的 this 連結到 Human 函式的 this 上,這樣子我們就達成了所有物件屬性的繼承:

function Human (height,race){
	this.height = height 
	this.race = race
}

function User(fisrtname,lastname,race,height){
	this.fisrtname = fisrtname 
	this.lastname = lastname 
  Human.call(this,height,race) 
}

Human.prototype.getHumanRace = function(){
	return this.race
} 

User.prototype.getFullName = function(){
	return this.firstname + this.lastname
} 

User.prototype = new Human()

let user1 = new User('John','Kai','black','179')   

https://ithelp.ithome.com.tw/upload/images/20191007/20106580dVHNzBM7ks.png

JS 原力覺醒 Day21 - 原型


上一篇提到 JS 是物件原型導向,而非物件導向的語言,如果想要像物件導向那樣達成物件之間屬性的共用,就需要借助原型的幫忙,所以了解「原型」的概念,對於我們後續想要活用 JS 的物件,或是在 JS 裡面撰寫物件導向風格的程式碼的話是非常重要的。

Outline

  • 物件導向:類別與物件
  • 物件導向:繼承的概念
  • 函式上的原型物件屬性
  • 透過函式建構子產生的物件
  • 總結:原型物件屬性

物件導向:類別與物件

在物件導向語言裡面,類別定義了描述某件事或某個功能的基本概念,就像一件商品或是建築物的設計圖ㄧ樣;而物件則是透過類別裡所描述的的概念實現出來的東西,對比於建築設計圖,就是建築物:

  • 類別 ←→ 建築設計圖
  • 物件 ←→ 建築物

https://ithelp.ithome.com.tw/upload/images/20191006/20106580XxkBIolrjB.jpg

當然上面的比喻只能說是非常非常粗淺的描述,完整的物件導向概念是非常博大精深的。這邊是想讓各位讀者了解它的原理,以及從什麼出發點被創造出來的,知道物件導向的根本概念後,後面我們解說 JS 原型的時候,就不會那麼不知所以然。

物件導向:繼承的概念

前面也提過原型存在的目的是為了達到繼承,那麼我們先來看看繼承是怎樣的概念,在物件導向裡的繼承是指類別可以以另一個類別為基礎,再往上進行擴充、或是修改,這樣一來就可以用很方便且較低成本的方式創造新的類別,因此,姑且說繼承的目的是為了讓「某些屬性可以共用」且可以減少重複。

https://ithelp.ithome.com.tw/upload/images/20191006/20106580UZUALPHjdm.jpg
用生活化的方式比喻的話繼承與被繼承物件之間的關係,有點像圖片內的「動物」這個總稱與「鳥」這樣更明確的稱呼,鳥也是動物的一種,所有動物都有特定共用的行為例如呼吸,但是有些行為可能只有鳥類做得出來例如飛行,因此可以知道,繼承可以讓物件同時具有共用的部分與較為特定的部分。

函式上的原型物件屬性

在物件導向裡面有類別的概念讓物件得以用很快速清楚的方式擴充,而 JS 裡面只有「物件」,所以只能用模擬的方式來達成類似的效果 — 那就要透過原型的幫忙。

前面在講繫結的時候我們提到,函式可以搭配 new 運算子成為「函式建構子」來產生物件,我們先來討論函式建構子的概念是什麼。在 JS 裡面,函式建構子其實與一般函式呼叫沒有差別,只是前面多了 new 這個關鍵字而已。

而在 JS 裡面,一個函式被創造出來的時候,JS 引擎會新增一個 prototype 屬性到這個函式上面,這個 prototype 是一個物件,我們姑且稱之為「原型物件」,在原型物件裡面我們可以找到一個指回該函式的constructor 屬性。

https://ithelp.ithome.com.tw/upload/images/20191006/20106580QnyFbprfLV.jpg

我們用下面的程式碼來當作例子:

 function User(firstName, lastName) {
	this.firstName = firstName,
	this.lastName = lastName,
	this.fullName = function() {
		return this.firstName + " " + this.lastName;
	}
}

var user1 = new User("Gin", "Tsai");

console.log(user1) 

我們用 User 函式當作函式建構子來產生物件,這個函式上面會有一個 prototype 屬性,且他是一個物件,裡面有另外兩個屬性:

  • 剛剛提到的 constructor 屬性,指向回該建構函式 ( User )
  • _proto__ 屬性 ,裡面又是另外一個物件,這一點後面會再詳談

透過函式建構子產生的物件

那麼,當物件透過這個函式建構子被產生之後,會不會有什麼特別的地方呢?相對於 JS 引擎在 function 上面加上 prototype 屬性,在這個新生成的物件上則是會被加上一個 __proto__ 屬性,這個屬性恰好是指向剛剛函式建構子的 prototype 物件。

https://ithelp.ithome.com.tw/upload/images/20191006/20106580aINQGXfUmW.png

User.prototype === user1.__proto__  //true

因此我們透過上面的例子可以得出這樣子一個結論:透過函式建構子生成的物件,其上面會有一個指向該物件所屬函式建構子 prototype 屬性的 __proto__ 屬性,也就是該新生成物件的「原型」。

https://ithelp.ithome.com.tw/upload/images/20191006/20106580sg56q0rUHF.jpg

現在讓我們用同樣的方式創造第二個使用者 user2 ,因為一樣都是透過函式建構子所產生的物件,因此在這個物件上照理說也會有一個 __proto__ 屬性並指向產生這個物件的函式建構子上的原型物件,所以我們可以知道只要是透過函式建構子被生成的物件,他們之間都有一個共享的原型物件( prototype,先知道這一點很重要。

https://ithelp.ithome.com.tw/upload/images/20191006/20106580UpsGcXBrmb.jpg

總結:原型物件屬性

現在我們知道了被生成物件與建構函式之間的關係:

所有透過函式建構子生成的物件,都透過 __proto__ 屬性與函式建構子上的 prototype 屬性做連結,或是說共享這個屬性。

但是光知道這些還沒有辦法知道實際的應用,下一章節我們會介紹這個部分,就讓我們往下看看 JS 是怎麼透過原型來達到繼承以及減少相同函式宣告的重複性的。

參考資源

Prototypes in JavaScript

JS 原力覺醒 Day20 - 物件

今天要提到 JS 裡面物件的概念,「物件」的概念在 JS 裡面是非常重要的,也是 JS 的基本元素。但是相對於物件導向語言的物件,意義上又有一點不一樣。就像前面提到在 JS 裡面函式也是屬於物件,這樣子的行為在一般物件導向的語言裡面是沒有的。
https://ithelp.ithome.com.tw/upload/images/20190916/20106580lJIWdcHc2t.png

Outline

  • JS 的物件
  • 創造物件的方式
  • 取用物件的方式
  • 物件原型導向

JS 的物件

在 JS 裡,物件代表一連串「屬性名稱」與「數值」的組合 ( name-value pair )。這些組合湊在一起就形成了對某件事情的描述,就像一本書有許多資訊像是書名、作者、出版日期ㄧ樣,你可以用 JS 物件輕鬆的表示現實世界的許多物品:

{
 title: 'Le Petit Prince', 
 author:'Antoine de Saint-Exupery', 
 pages: '331', 
 ...
} 

創造物件的方式

最基本的用來創造物件的方式有幾種:

  • 物件實字 ( Object Literal )
  • 函式建構子

物件實字 ( Object Literal )

物件實字應該是你最長用到的創造物件方式,使用物件實字創造物件的寫法,跟在 API 傳遞、溝通的時候會用到的 JSON 格式長得很像,都是使用大括號逗號來區分屬性,其實我在文章的開頭就已經使用過了:

let object = { propertyName : 'value', ...} 

函式建構子 ( Function Constructor )

在許多物件導向語言裡面,因為以類別為主的語言特性,通常是以 class 創造物件藍圖,並搭配使用 new 關鍵字來產生新的物件,這也是物件導向的基本概念。雖然 JS 並不是物件導向的語言,但早期為了吸引那些習慣使用物件導向語言的工程師來使用,也創造了使用 new 關鍵字,屬於自己獨特的產生物件的方式,稱為「函式建構子」,也就是把函式內容視為其他物件導向語言的建構子( constructor ) 來使用:

function book  (name,price) {
	this.name = name; 
	this.price = price  
} 

let starWar = new book('star war', 500) 

console.log(starWar) // book {name: "star war", price: 500} 

如果你要產生一個空物件,那麼除了物件實字,你也可以透過下面的方式:

 let obj  = new Object(); 

這是什麼意思?我們都知道 JS 裡面有一個物件叫做 Object,裡面有很多好用的 API 例如 Object.keys 可以取得物件的所有屬性名,但是根據上面的說明,new 應該要搭配函式來使用才對啊?難道 Object 是函式不成?

是的! 在 JS 裡面 Object 就是一個函式,你可以對他使用 typeof 來驗證這個說法:

 typeof Object // function 

既然 Object 本身也是函數,那麼這個說法就合理了,至於為什麼 typeof Object 結果不是 Object ,我想那又是另外一個層面的問題了。

取用物件屬性的方式

取用物件有兩種方式:

  • 最常見的.運算子
  • 使用中括號 []

使用中括號取用物件來取用屬性,因為能夠使用字串的關係,在取用屬性的時候可以比較有彈性:

let user = {
	name:'Yoda'
} 
user.name // Yoda
user['name'] //Yoda

物件原型導向

雖然許多人在 JavaScript 撰寫物件導向風格的程式碼,但 JS 並不是像 JAVA 或是 C# 那樣物件導向的語言,而相對的,JS 是物件原型導向( Object-Prototype Oriented )的語言,在 JS 裡面的每個物件都有一個可以用來與其他物件共用屬性跟方法,或是進行複製的隱藏屬性 : [[ Prototype ]]

這種繼承的行為也稱為原型繼承 ( prototypical inheritance ),相對於其他像是 PHP、JAVA、Python 這種以類別 ( class ) 為基礎的物件導向語言,這算是比較特別的,在後面的章節我會繼續說明 JS 的物件是如何透過原型來共用屬性的。

JS 原力覺醒 Day19 - 一級函式與高階函式

今天要提到的是讓 JS 很適合用來撰寫 Functional Programming 的兩個特性的名詞解釋:「 一級函式」與「高階函式」,如果你寫 JS 一段時間,一定會聽過他,高階函式與一級函式可能聽起來有點複雜,其實並不會,只是字面上意思比較不好理解而已。這兩個特性,讓 JS 可以把函式在其他函式之間互傳,所以也是為什麼有人說 JS 很適合用來寫 Functional Programming 的原因。

Outline

  • 一級函式
  • 高階函式

一級函式 ( First-class functions )

當我們說一個語言具有一級函式的特性時,代表這個語言把函式當作其他物件一樣看待,也因此可以將函式當作參數一樣傳入另外一個函式裡面。在 Functional Programming 裡面,也是因為這個特性,才有辦法做到複合函式 (Function Composition),

而在 JS 內,函式本身也是一個特殊的物件(就是 Function 物件),在一些使用到 callback 概念的程式碼中,你就會看到這個概念是如何被應用的:

function doSomething(fn, data) { 
   return fn(data);
}

我們可以試試下面的程式碼來確認上面的描述 :

 function hello (){
		console.log('hello') 
	} 

hello.a = 'a'
console.log(hello.a) //'a' 

雖然上面的程式碼完全是合法的,因為函式本來就也是物件,但是在實務上請不要這麼做,否則同事或是跟你一起合作的人可能會崩潰,請使用一般的物件。

而既然將函式當作物件一樣看待,那就代表也可以把這個函式指派給變數,這就是我們之前提到的「函式表達式」 ( Function Expression ) 。

let hello = function (){
	//do  some thing 
} 

高階函式 ( High Order Function )

只要是可以接收函式作為參數,或是回傳函式作為輸出的函式,我們就稱為高階函式,例如,JS 裡面很常用的一些對陣列操作的內建API:

  • Array.map( ()⇒{…} )
  • Array.filter( ()⇒{…} )
  • Array.reduce( ()⇒{…} )

也可以被稱為是高階函式,因為他們能夠接收函式作為他們的參數。雖然上述幾個 API 的使用方式乍看之下可能會讓人覺得難以理解,但我們可以試著思考看看他們是怎麼被實作的,其實並沒有那麼複雜,下面就以 Array.map 為例,邊實作、邊思考他的運作方式吧!

由於 Arrray.map 是對陣列元素做巡訪,然後做某些操作之後回傳,所以可能的步驟如下:

  1. 將函式傳入 map 內
  2. 執行一個以陣列長度為執行次數的迴圈
  3. 每次帶入不同的 array id 以表示目前尋訪的進度
  4. 取得陣列元素、逐個進行修改
  5. 逐個放入新的陣列並回傳

自己實際實作 map function 的話看起來會像是這樣:

function arrayMap(fn,array){
	let length = array.length
	let newArray = [] 
	for(let i=0 ; i<length ; i++){
		newArray.push(fn(array[i]))
	}
	return newArray
}

透過上面的程式碼我們自己就實作了高階函式 arrayMap ,可以看到我們自己做的 arrayMap 會在陣列傳入之後,逐個訪問每個元素並傳入我們自己寫的函式 fn ,這個 fn 會根據我們寫的內容將該值做處理之後回傳,然後會直接透過 Array.push 將結果推入新的函式( 看到了嗎?這裡我們用到複合函式的概念 )

arrayMap((item)=>{
	return item * 2 	
},[1,2,3,4])

結論

透過今天對兩個名詞的說明我們知道了一級函式與高階函式這兩個名詞的意義,然後我們也自己試著實作了自己的高階函式:

  • 一級函式是指在一個語言內,函式本身也是物件,因此能夠將函式當成參數傳給另一函式
  • 高階函式則是指一個函式能不能接收函式當作參數,或是回傳函式作為回傳值
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×