動機

今時今日所謂的網站正進化成網路應用程式,它不再只是單純的顯示圖片文字資訊,而包含著更多互動與操作行為,同時也意味著一個網站:

  • 具有更多的 Javascript
  • 可以在現代的瀏覽器上做更多事
  • 較少全頁重新載入的行為 ➞ 甚至更多程式碼在單一頁面

其結果就是有更多程式碼出現在客戶端(Client side)
有大量的程式碼需要被組織化。模組化系統提供一種方式讓我們可以切割我們的程式碼使其變成個別的模組。

如果您是實作派的可以直接看 跟著官方文件實作一遍 下面除了官方入門,同時也搭配 Pete Hunt 的 webpack-how-to 實作一遍常用的功能

模組化系統的風格

針對如何定義模組之間的相依性,在 JS 世界中有很多不同的標準:

  • <script> 標籤(不具備模組化系統)
  • CommonJS
  • AMD 以及其衍伸的標準
  • ES6 模組
  • 其他

<script> 標籤

當你不使用任何其他模組化系統,這是你在網頁中處理模組或說切割 JS 檔案的方法。

<script src="module1.js"></script>
<script src="module2.js"></script>
<script src="libraryA.js"></script>
<script src="module3.js"></script>

這種方式通常一個模組會匯出介面到全域物件,即 window 物件,模組可以透過全域物件存取相依的介面或叫方法。
常見的問題

  • 在全域物件中產生衝突
  • 載入的順序非常重要,錯了其他需要相依的函式庫就不能用
  • 開發者必須要自己解決模組和函式庫之間相依性的問題
  • 在大型專案中這一串載入的列表可能非常長,難以維護

CommonJS 同步 require

這種方式採用同步風格的 require 方法,類似我們 C#using, Rubyrequireload,透過這個方法載入相依的函式庫並匯出一系列介面
一個模組可以透過在 exports 加上屬性(Property)或 module.exports 的值來設定其介面,這段話有點抽象換成白話一點的解釋: 根據 CommonJS 標準,一個檔案即一個模組。載入模組使用 require 方法,這個方法會讀取檔案並執行,最後回傳檔案內部 exports 的物件。

// 基礎的用法
require('module');
require('../file.js');
exports.doStuff = function () {};
module.exports = someValue;

// 簡易的範例
/***  car.js ***/
function Car() {
  this.run = function () {
    console.log("Car run...");
  }
  this.stop = function () {
    console.log("Car stop!!");
  }
}

var car = new Car();
module.exports = car;

/***  main.js ***/
var Car = require("./car");
Car.run(); // Car run...

明白了一點點用法後我們知道 CommonJS 載入模組是同步的。

優點

  • 伺服器端模組可以被重複使用
  • 已經有許多 npm 的模組採用這種風格
  • 因為其語法和用起來簡單易懂

缺點

AMD 非同步載入

因為瀏覽器的需求以及同步 require 的問題,所以引進了一個非同步的版本

require(['module', '../file'], function(module, file) { /* code here */});

define('mymodule', ["dep1", "dep2"], function(d1, d2) {
  return someExportedValue;
});

優點

  • 符合網路非同步載入的需求
  • 可多模組平行載入

缺點

  • 需撰寫比較多的程式碼,比較難讀寫(對開發者來說)和維護
  • 看起來像是某種取巧的解法 實作
  • require.js
  • curl

ES6 模組

ECMAScript6 內建的用法

import "jquery";
export function doStuff() {}
module "localModule" {}

優點

  • 靜態解析非常容易
  • 未來將會是標準 缺點
  • 瀏覽器全面支援需要花些時間
  • 非常少模組已採用此種方式

兼容的解決方案

讓開發者選擇模組化的標準,讓已存在的程式碼可以運作,使其可以輕鬆的加入其他模組標準。

關於傳輸

模組通常會在客戶端執行,所以必須從伺服器端傳輸到瀏覽器。

這邊有兩種關於傳輸模組的極端例子:

  • 每一個模組一個請求
  • 所有模組整合成一個請求

兩者都被廣泛的使用,但也都不是最佳的做法

關於一個模組一個請求

  • 優點: 只有需要的模組會被傳輸,不會傳一堆不相關的東西
  • 缺點: 太多 request
  • 缺點: 因為 request 太多導致可能害應用程式初始化或者第一次載入時很慢

所有模組整合成一個請求

  • 優點: 較少的請求數,程式開始的時候比較快
  • 缺點: 不需要的模組也會被一併傳輸

分組傳輸

一種比較彈性的傳輸,在上面兩種極端的方法中取得平衡的折中作法。
在編譯所有模組時: 將系列模組區分成多個較小的區塊(程式碼片段)
如此一來就不用在初始化的時候一口氣全部載入,只要根據需求載入即可

為什麼不僅僅只載入 Javascript?

我們應該反問為什麼模組化系統只協助開發者處理 Javascript? 還有其他靜態資源檔案需要被處理:

  • stylesheets
  • images
  • webfonts
  • html for templating

還有其他

  • coffeescript ➞ javascript
  • less stylesheet ➞ css
  • jade ➞ html
  • i18n ➞ something
require("./style.css");
require("./style.less");
require("./template.jade");
require("./image.png");

因為上面這些動機,所以您找到了 webpack。

Webpack 是什麼?

webpack 簡單說就是一個模組的封裝工具(module bundler),由德國的 Tobias Koppers 所開發。webpack 會將模組與其相依性的模組, 函式庫, 其他需要預先編譯的檔案等整合產生此模組的靜態資源檔


嫌太饒舌,那我們直接看官方的圖片,就是把我們常用的 .less, .scss, .jade .jsx 等等的檔案編譯成單純的 js + 圖片(圖片有時候也可以被編譯成 base64 格式的 dataUrl)。
第一次接觸 Webpack 的人可能會忽略這個重點(小弟就是其一),那就是編譯後的靜態資源檔真的就如圖上所示,只有 js + 圖片css也會被編譯到 js 中,也就不需要在額外匯入。
達到真正的模組化。看看下圖一隻編譯完成的檔案

為什麼不用其他 bundler?

已存在的 bundler 針對大型專案並不是真的那麼適合,這裡指的是大型的 SPA。為什麼要創造 webpack 最重要的動機就是需要 Code Splitting 拆分程式碼,同時像是 css, 圖片等等靜態資源檔需要無縫整合。這邊的拆分程式碼指的是依照需求,功能來區分模組達到關注點分離。
如果您曾經試過其他 bundler 他們並無法達到這個目的。大部份都只是個別組織 JS 檔案和靜態資源檔案。因此 webpack 為了滿足這個動機而誕生。

目標

  • 能夠拆分相依性的關係結構變成程式碼片段,然後依據需求載入
  • 盡可能減少初始化載入的時間
  • 每一個靜態資源檔也應該要能被模組化
  • 有能力整合其他第三方函式庫為模組
  • bundler 絕大部份能夠依照需求自訂修改
  • 適合大型專案

Webpack 有哪些不同?

Code Splitting 拆分程式碼

webpack 在其相依性結構(Dependency tree)中有兩種相依的類型: syncasync。以非同步相依作為分割點,形成一個新的片段。當 chunk tree 程式碼片段之間的結構被優化之後,就會透過一個檔案整合發佈每一個 chunk 即每個片段程式碼。

loaders 載入器

載入器當然是翻得不好,一般來說其意義就是負責載入安裝程式的角色,所以這邊我們還是稱其為 loader。雖然 webpack 本身只能夠處理 Javascript,不過因為有 loaders,可以被用來轉換其他資源為 Javascript ,透過這種方式每一個資源檔都可以被轉換成模組形式。
換個方式來比喻其實 html 的 <link rel="stylesheet" /> 就是一個 css 載入器的角色,又或者有用過 browserify 的人所熟悉的 transforms。
其功能為轉換解析 ➞ 載入 ➞ 使用。

智慧型解析

webpack 擁有更聰明的解析工具可以處理幾乎所有的第三方函式庫。甚至允許在相依性設定上使用表達式,例如: require("./templates/" + name + ".jade")
這幾乎能處理大部份的模組化標準(CommonJS, AMD)

擴充套件系統

webpack 擁有豐富的擴充套件。大部份內部的功能都是架構在擴充套件之上。這使得我們能夠自訂客製 webpack 來滿足我們的需求,並且可以發佈成通用套件為 Open Source
至此我們對於 webpack 有了一點概念性的了解。

安裝 webpack

node.js

使用 webpack 之前我們需要安裝 node.js 以及其內建的套件管理工具 npm

webpack

接著就可以透過 npm 直接安裝 webpack

$ npm install webpack -g

透過 -g 參數 webpack 會被安裝在系統全域環境同時具備 webpack 指令

在專案中使用 webpack

在專案中使用 webpack 最好也讓專案相依於 webpack,即透過 npm 為專案安裝 webpack。透過這個方式我們可以選擇調整 webpack 的版本,而不必被強迫使用全域的版本。
建立專案的步驟首先需要建立一個 package.json 設定檔或者直接使用 npm 指令來產生

$ npm init

關於 npm init 會用互動的方式在指令介面問你的問題,如果專案不會公開出去的話其實也不是太重要
接著一樣透過 npm 指令安裝 webpack

$ npm install webpack --save-dev

版本

一般來說 webpack 同時間會有兩個版本。穩定版以及 Beta 版本。Beta 版會加後綴 -beta 在版號後面。Beta 版本可能會部分實驗性或較不穩定缺少足夠測試的功能,對於需要較嚴謹的東西應該使用穩定版較佳。
您可以透過指令來指定安裝的版本

$ npm install webpack@1.2.x --save-dev

接著我們就可以來看看該如何使用

指令介面

安裝全域指令

$ npm install webpack -g

單純編譯指令

$ webpack <entry> <output>

entry

傳入一個檔案或者路徑字串。您可以傳入多個程式進入點檔案(每一個檔案將會在啟動期間被載入),entry 用實作的行為來說明就是那隻用來 require 其他模組的檔案。
另外如果你使用 <name>=<filename/request> 的格式您可以替 entry point 建立一個別名。用法如下

$ webpack bar=./entry.js "[name].js"
>> output a bar.js file.

同時這個名稱也會被對應到設定檔 entry,太難懂!!沒關係我們換個實際例子來證明這段說明,首先我們建立一個 webpack.config.js

// webpack.config.js
module.exports = {
  output: {
    filename: "[name].bundle.js"
  }
}

接著執行指令

$ webpack FooBar=./entry.js
>> Output a file that is named FooBar.bundle.js

output

參數: 表示欲輸出的路徑,其會被映射到設定檔中的 output.path 以及 output.filename

設定參數

webpack 有很多參數能夠直接從指令去設定然後對應到設定檔,即 --debug 對應到 debug: true--output-library-target 對應到 output.libraryTarget

套件

有些套件被映射到指令的參數選項,即 --define <string>=<string> 會對應到 DefinePlugin

Development 縮寫 -d

等同於 --debug --devtool source-map --output-pathinfo,產生 source maps 檔案

Production 縮寫 -p

等同於 --optimize-minimize --optimize-occurence-order,建置壓縮的程式碼

監視模式 --watch

會一直監視所有的相依檔案當其改變時自動重新編譯,適用於開發模式持續性的更新編譯

指定設定檔 --config

設定不同於預設的設定檔。如果您希望採用不同於預設 webpack.config.js 的設定檔可以採用 --config 來指定

顯示參數

--progress: 顯示編譯的進度和訊息
--json: 產生 JSON 格式的 stdout
--color: 彩色模式
--sort-modules-by, --sort-chunks-by, --sort-assets-by: 排序
--display-chunks: 顯示模組區分的資訊
--display-error-details: 顯示更多關於錯誤訊息

跟著官方文件實作一遍

首先是您當然需要安裝 node.js 接著安裝 webpack。

$ npm install webpack -g

開始組織專案

建立一個空目錄來置放我們的檔案,在該目錄底下建立 entry.js

// File: entry.js
document.write("It works.");
<!-- File: index.html -->
<html>
  <head>
    <meta charset="utf-8" />
  </head>
  <body>
    <script src="bundle.js" charset="utf-8" />
  </body>
</html>

執行

$ webpack ./entry.js bundle.js

就會將我們的 entry.js 編譯成 bundle.js
如果編譯成功,就會輸出類似下面的資訊

~~~
Hash: e97678c23acf8ee01956
Version: webpack 1.9.10
Time: 62ms
Asset Size Chunks Chunk Names
bundle.js 1.44 kB 0 [emitted] main
[0] ./entry.js 29 bytes {0} [built]
~~~

接著開啟 index.html 如下圖

第二個資源檔案

接著我們模擬實際專案的狀況,匯入另外一個檔案 content.js

// content.js
module.exports = "It works from content.js";

然後編輯 entry.js 加入 require

// File: entry.js
document.write(require("./content.js"));

更新瀏覽器得到如下圖

Webpack 會分析進入點檔案並取得相依的其他檔案。這些檔案(被稱為模組)也會被加入到 bundle.js。Webpack 會給每一個模組唯一的 ID 然後透過 ID 存取這些模組,這些模組都會被整合到 bundle.js 裡面。只有進入點的模組會在程式啟動時被執行。這個小範例示範了 require 以及當相依模組被 require 載入後執行的用法。

第一個 loader

現在我們遇到一個問題,我們想要加入 css 到該應用程式中,Webpack 預設只能夠處理 JS 檔案,所以我們需要 css-loader 來處理 css 檔案。接著透過 style-loader 來把樣式套用到 DOM 上。

建立一個空的 node_modules 目錄(或者您要使用 npm init),事實上直接使用 npm install 也是會自動建立 node_module 目錄。
執行

$ npm install css-loader style-loader

加入 style.css

body {
  background: yellow;
}

再次編輯 entry.js

require("!style!css!./style.css");
document.write(require("./content.js"));

重新編譯並重整瀏覽器得到

透過在匯入模組(在這邊就只是一隻檔案)前加上 ! 和 loader 的前綴字,該模組將會逐步透過每一個 loader 處理,一個 pipeline 的概念,一個處理完交棒給下一個處理,這些 loader 會將檔案中的內容根據特定需求轉換。在經過這些轉換的過程之後最終的結果就是一個 javascript 模組。

綁定 loaders

實務上,我們並不希望一直重複撰寫這種長長的 pipe 方式,即 require("!style!css!./style.css");
我們可以根據副檔名綁定或說設定其 loaders,如此一來我們就只要寫 require("./style.css")

改寫 entry.js

// entry.js
require("./style.css");
document.write(require("./content.js"));

透過指令的方式繫結

$ webpack ./entry.js bundle.js --module-bind 'css=style!css'

# 有一點要注意的是因為 ! 在 bash 裡面有特殊意義所以當您想用 " 替代 ' 請記得跳脫
$ webpack ./entry.js bundle.js --module-bind "css=style\!css"

您應該會看到跟上面黃色底一樣的結果

設定檔

除非你是下指令狂,不然您應該不會希望每次指令都這麼長,這時我們可以把這些參數移到一個設定檔裡 webpack.config.js

module.exports = {
  entry: "./entry.js",
  output: {
    path: __dirname, // 此設定檔案所在的目錄
    filename: "bundle.js"
  },
  module: {
    loaders: [
      { test: /\.css$/, loader: "style!css" }
    ]
  }
}

一旦您有了設定檔,現在你只需要執行

$ webpack

webpack 指令會試圖去載入當前目錄下的 webpack.config.js

整潔易看的輸出

隨著我們的專案增長,編譯的時間可能會稍微長一點點。所以我們希望在編譯的時候有進度表以及我們希望輸出的資訊可以有顏色以便我們好觀察
這個時候我們可以透過下面參數達成

$ webpack --progress --colors

監視模式 watch

又或許在開發時期我們不希望一直手動輸入指令

$ webpack --progress --colors --watch

Webpack 可以快取沒有改變的模組。

當使用監視模式,webpack 會觀察專案底下所有在編譯時會用到的檔案,如果這些檔案發生改變,馬上會重新編譯
當快取被啟動的時候 webpack 會將所有模組存在記憶體中,如果模組沒有改變就會繼續沿用。

開發時期伺服器

更好用的開發時期的伺服器 webpack-dev-server

# 安裝
$ npm install webpack-dev-server -g
# 啟動
$ webpack-dev-server --progress --colors

提供一個 localhost:8080 的 express server ,讓我們在開發時期可以更快速的觀察結果,當然會自動編譯,同時自動更新頁面(socket.io)
這個工具使用 webpack 的監視模式所以編譯的結果

這個開發伺服器使用了 webpack 的監視模式。同時他也會阻止 webpack 持續把編譯結果存到硬碟上,取而代之的這個結果會被保留在記憶體。
不要誤會!這邊說的是如果你單純使用 webpack 監視模式,上例中的 bundle.js 檔案是會被產生的,但如果是 webpack-dev-server 則不會產生 bundle.js 那隻檔案。

webpack-how 跟著 Pete hunt 的文件再跑一輪

這個段落我們會在翻譯以及實作 webpack-howto 來加深我們對 webpack 的理解,老實說因為官方的文件並不是非常完整。

1. 為什麼使用 webpack (Pete hunt 版)

  • 它很像 browserify,但是他可以分割程式為多個檔案。例如您在一個單一頁面應用程式(SPA)中有數個頁面,那麼使用者只需要下載正在閱讀的那一頁,如果他切換到另個頁面,也不會重新下載共用部分的程式碼
  • 大多數的情況下可以取代 grunt 或 gulp 因為他也可以封裝 css, 預先編譯的 css 語言, 預先編譯的 js 語言, 圖片以及其他東西

同時它支援 AMD 和 CommonJS 以及其他模組標準。如果您不知道該使用什麼,就用 CommonJS

2. 對於會用 Browerify 的開發者

下面兩個指令是等價的

$ browserify main.js > bundle.js

$ webpack main.js bundle.js

然而 webpack 比起 Browserify 更加強大,也因為支援許多功能,所以一般來說我們會將設定存放在 webpack.config.js 這隻設定檔。
讓我們再來多練習一次,建立一個 webpack_sandbox 目錄,裡面自己放一些簡單的 main.js 主要的進入點程式,index.html 測試載入 bundle.js 是否正常運作,以及最重要的 webpack.config.js 如下

module.exports = {
  entry: './main.js',
  output: { 
    filename: 'bundle.js'
  }
}

這個 webpack.config.js 就只是 Javascript ,所以就像你平常寫 js 一樣修改它即可。

3. 如何執行 webpack

一般來說我們常用的編譯指令如下,記住先切換到 webpack.config.js 所在的目錄底下然後執行

  • webpack 建置編譯開發版的檔案,只會運行一次
  • webpack -p 執行一次建置的任務,產生正式版(具有壓縮)
  • webpack --watch 持續性編譯,即開發時期,每次一變更檔案就重新編譯(快速)
  • webpack -d 包含產出 source maps,即 .js.map 檔案

4. 預先編譯的 JS 語言

在 webpack 中有個跟 browserify 的 transforms 以及 RequireJS plugin 功能相等的東西,就是 loader。下面示範如何讓 webpack 載入 CoffeeScript 和 Facebook 的 JSX + ES6 支援(您必須要安裝 babel-loader coffee-loader),因為 babel 內建搭載支援 JSX 所以您不需要再增加額外的 jsx-loader。

為了要實際測試,我們需要再目錄中建立一個 coffee, React 元件(JSX + ES6 支援)
首先先測試 coffee 所以我們新增一隻測試的 coffee

# File: audi.coffee
value = "It's from audi.coffee" if true # it's coffeescript syntax.
module.exports = value;

安裝 coffee-loader

{% highlight bash %}
$ npm init
$ npm install coffee-loader --save-dev
{% endhighlight %}

調整 webpack.config.js

// File: webpack.config.js
module.exports = {
  entry: './main.js',
  output: {
    filename: 'bundle.js'
  },
  module: {
    loaders: [
      { test: /\.coffee$/, loader: 'coffee-loader' },
    ]
  }
}

測試,在 main.js 中使用

// File: main.js
document.write("Hey from main.js");
document.write("<br/>");

var audi = require("./audi.coffee");
document.write(audi);
document.write("<br/>");

接著我們來測試 jsx 與 React,記得先安裝 babel-loader

$ npm install babel-loader --save-dev

調整 webpack.config.js 為

module.exports = {
  entry: './main.js',
  output: {
    filename: 'bundle.js'
  },
  module: {
    loaders: [
      { test: /\.coffee$/, loader: 'coffee-loader' },
      { test: /\.js$/, loader: 'babel-loader'},
    ]
  }
}

新增一隻 React 元件檔案 toyota.js

// File: toyota.js
export default class Totota extends React.Component {
  constructor(props) {
    super(props);
  }
  render() {
    return (
      <div>
      It's from toyota.js
      </div>
    );
  }
}

最後 main.js(記得在 index.html 補上 React 的 JS)

// File: main.js
document.write("Hey from main.js");
document.write("<br/>");

var audi = require("./audi.coffee");
document.write(audi);
document.write("<br/>");

document.write("<div id='toyota'></div>");
var Toyota = require("./toyota.js");
// 另外一種模組標準的寫法
// import Toyota from "./toyota.js";
React.render(<Toyota />, document.getElementById("toyota"));

每次在 require 的時候都要輸入附檔名也是挺麻煩的,所以 webpack 也提供您 require 不加副檔名的機制,為了開啟這個功能,我們必須要加入 resolve.extensions
參數告訴 webpack 該處理哪些副檔名。

module.exports = {
  entry: './main.js',
  output: {
    filename: 'bundle.js'       
  },
  module: {
    loaders: [
      { test: /\.coffee$/, loader: 'coffee-loader' },
      { test: /\.js$/, loader: 'babel-loader' }
    ]
  },
  resolve: {
    // 現在您可以把那些 require 中的副檔名去掉了
    extensions: ['', '.js', '.json', '.coffee'] 
  }
};

如果您是採用 webpack-dev-server 在修改 config 之後請記得重啟

如果檔名一樣會怎樣,在正常的專案底下不同類型的資源檔通常會用不同的目錄區隔,不過在這個簡單的範例中的確是有可能會重複的。
webpack 其實會照上面 resolve 設定的陣列依序搜尋,找到了就不往下了。也就是如果有同名的 js 和 coffee 會處理 [檔名].js 而不管 coffee。

5. 樣式與圖片

接著我們要來實作在模組中透過 require() 使用那些靜態資源檔。
先示範在程式中我們會改成這樣參考資源檔,像 css, 圖片等等

require("./bootstrap.css");
require("./app.scss");

var img = document.createElement("img");
img.src = require("./images/pretty.jpg");
document.body.appendChild(img);

當我們 require css 或者 scss, less 等等的時候,webpack 會把 css 轉換一行的字串並封裝在 JS 中,然後當我們執行 require() 會幫我們插入 <stype> 標籤到該頁面
而當我們 require 圖片的時候,webpack 則會把圖片轉換成 dataURI 或帶入連結。

當然這些都不是預設有的功能,你必須透過 loaders 告訴 webpack 該怎麼做,我們需要的 loaders,css-loaderstyle-loader 處理樣式,sass-loader 當然是處理 scss,url-loader 則負責處理圖片(檔案)類似。您也可以使用 file-laoder 不過 url-loader 可以設定限制檔案大小回傳 dataURI 或路徑。

這邊我們額外提一下上面說的流程中 css-loader 才是真正在解析 css 檔案,並且他會解析 css 中的 url(...) 轉換成 require(...) ,如此一來所有的資源都會依照 webpack 的處理方式載入,而 style-loader 收到這個輸出之後會把這些轉換完的結果注入 DOM 。

安裝 loaders

$ npm install css-loader style-loader sass-loader url-loader --save-dev

設定 webpack.config.js

// webpack.config.js
module.exports = {
  entry: './main.js',
  output: {
    path: './build',
    publicPath: 'http://andyyou.github.io/', // 圖片等需要路徑引用的資源檔加上網址
    // 注意: 尾巴的 / 要記得加否則會產出類似 http://andyyou.github.io600e2b78b83128cc2be868b3971d0999.jpg 的路徑
    filename: 'bundle.js'
  },
  module: {
    loaders: [
      { test: /\.coffee$/, loader: 'coffee-loader' },
      { test: /\.js$/, loader: 'babel-loader'},
      { test: /\.css$/, loader: 'style!css' },
      { test: /\.scss$/, loader: 'style!css!sass'}, // => 透過 css-laoder 不只處理編譯好的 css, imports 同時包含 url(...)
      // { test: /\.css$/, loader: 'raw!sass' }, // => 回傳編譯好的 css 程式碼單純只解析 imports 但不處理 url(...)
      { test: /\.(png|jpg)$/, loader: 'url-loader?limit=8192' } // 當檔案小於 8K 的時候會產生 base64 格式的 dataURI 超過的話則直接帶連結
    ]
  },
  resolve: {
    extensions: ['', '.js', '.json', '.coffee', '.scss', '.css']
  }
}

6. 功能標籤

我們想要某些程式碼只在特定環境下才執行,例如顯示偵錯訊息,又或者只在內部伺服器才開啟這個功能。
因為我們是使用 webpack 來封裝編譯整個專案,所以很合理的可以加上一些 flag 讓 webpack 去替我們處理。
我們可以直接在剛剛 main.js 中示範

// main.js
if (__DEV__) {
  console.warn("It's dev environments")
}

if (__PRERELEASE__) {
  console.log("requre and show secret feature.")
}

不過我們不是直接就可以使用魔術般的全域變數。我們還是需要告訴 webpack 才行
下面這邊示範在 webpack 官方提到的 plugins 用法

var webpack = require("webpack");

var definePlugin = new webpack.DefinePlugin({
  __DEV__: JSON.stringify(JSON.parse(process.env.BUILD_DEV || 'true')),
  __PRERELEASE__: JSON.stringify(JSON.parse(process.env.BUILD_PRERELEASE || 'false'))
});

module.exports = {
  entry: './main.js',
  output: {
    path: './build', // 編譯後的檔案放在這個目錄
    // publicPath: 'http://andyyou.github.io/', // 圖片等需要路徑引用的資源檔加上網址
    // 注意: 尾巴的 / 要記得加否則會產出類似 http://andyyou.github.io600e2b78b83128cc2be868b3971d0999.jpg 的路徑
    filename: 'bundle.js'
  },
  module: {
    loaders: [
      { test: /\.coffee$/, loader: 'coffee-loader' },
      { test: /\.js$/, loader: 'babel-loader'},
      { test: /\.css$/, loader: 'style!css' },
      { test: /\.scss$/, loader: 'style!css!sass'}, // => 透過 css-laoder 不只處理編譯好的 css, imports 同時包含 url(...)
      // { test: /\.css$/, loader: 'raw!sass' }, // => 回傳編譯好的 css 程式碼單純只解析 imports 但不處理 url(...)
      { test: /\.(png|jpg)$/, loader: 'url-loader?limit=8192' } // 當檔案小於 8K 的時候會產生 base64 格式的 dataURI 超過的話則直接帶連結
    ]
  },
  resolve: {
    extensions: ['', '.js', '.json', '.coffee', '.scss', '.css']
  },
  plugins: [definePlugin],
}

接著我們就能夠用 BUILD_DEV=0 BUILD_PRERELEASE=1 webpack, 或者 BUILD_DEV=0 BUILD_PRERELEASE=1 webpack-dev-server --progress --colors
來帶入參數,注意到 webpack -p 壓縮程式碼的時候會把不會執行的程式碼區塊給移除,所以我們不需要擔心洩露機密的程式碼到最後產出的檔案中。

7. 多個檔案(進入點程式, entrypoints)

截至目前為止我們都只有一個 entry 即 main.js ,假設我們需要替個人資料頁訂閱頁面各自加入自己擁有的 JS,因為我們不希望讓使用者在查閱個人資料時載入訂閱頁面需要的程式碼。所以我們需要打包成兩隻檔案,也就是這兩個頁面各自有自己的 entrypoint
此時我們只需要修改設定檔

// webpack.config.js
var webpack = require("webpack");

var definePlugin = new webpack.DefinePlugin({
  __DEV__: JSON.stringify(JSON.parse(process.env.BUILD_DEV || 'true')),
  __PRERELEASE__: JSON.stringify(JSON.parse(process.env.BUILD_PRERELEASE || 'false'))
});

module.exports = {
  entry: {
    Main: './main.js',
    Profile: './profile.js',
    Feed: './feed.js'
  },
  output: {
    path: './build',
    // publicPath: 'http://andyyou.github.io/', // 圖片等需要路徑引用的資源檔加上網址或路徑
    publicPath: '/build/', // 因為有設定目錄,所以記得要補路徑,否則 require() 會取錯路徑。
    // 注意: 尾巴的 / 要記得加否則會產出類似 http://andyyou.github.io600e2b78b83128cc2be868b3971d0999.jpg 的路徑
    filename: '[name].bundle.js' // [name] 會使用 key 也就是上面大寫的 Main, Feed, Profile 等
  },
  module: {
    loaders: [
      { test: /\.coffee$/, loader: 'coffee-loader' },
      { test: /\.js$/, loader: 'babel-loader'},
      { test: /\.css$/, loader: 'style!css' },
      { test: /\.scss$/, loader: 'style!css!sass'}, // => 透過 css-laoder 不只處理編譯好的 css, imports 同時包含 url(...)
      // { test: /\.css$/, loader: 'raw!sass' }, // => 回傳編譯好的 css 程式碼單純只解析 imports 但不處理 url(...)
      { test: /\.(png|jpg)$/, loader: 'url?limit=8192' } // 當檔案小於 8K 的時候會產生 base64 格式的 dataURI 超過的話則直接帶連結
    ]
  },
  resolve: {
    extensions: ['', '.js', '.json', '.coffee', '.scss', '.css']
  },
  plugins: [definePlugin],
}

設好之後,接著我們就可以透過 <script src="build/Profile.bundle.js"></script> 針對個別頁面載入

8. 優化通用的程式碼

假設上面的 Feed 和 Profile 有很多通用的部分(比如說 React 元件和通用的樣式)
webpack 會分析他們哪些是共用的部分,如此一來共享的部分就會直接被快取,不用再重新載入一次。
透過使用 new webpack.optimize.CommonsChunkPlugin 如下

// File: webpack.config.js
var webpack = require("webpack");

var definePlugin = new webpack.DefinePlugin({
  __DEV__: JSON.stringify(JSON.parse(process.env.BUILD_DEV || 'true')),
  __PRERELEASE__: JSON.stringify(JSON.parse(process.env.BUILD_PRERELEASE || 'false'))
});

var commonsPlugin = new webpack.optimize.CommonsChunkPlugin('common.js');
// => 注意到這邊的參數會轉換成檔名輸出所以請記得加副檔名

module.exports = {
  entry: {
    Main: './main.js',
    Profile: './profile.js',
    Feed: './feed.js'
  },
  output: {
    path: './build',
    // publicPath: 'http://andyyou.github.io/', // 圖片等需要路徑引用的資源檔加上網址或路徑
    publicPath: '/build/', // 因為有設定目錄,所以記得要補路徑,否則 require() 會取錯路徑
    // 注意: 尾巴的 / 要記得加否則會產出類似 http://andyyou.github.io600e2b78b83128cc2be868b3971d0999.jpg 的路徑
    filename: '[name].bundle.js'
  },
  module: {
    loaders: [
      { test: /\.coffee$/, loader: 'coffee-loader' },
      { test: /\.js$/, loader: 'babel-loader'},
      { test: /\.css$/, loader: 'style!css' },
      { test: /\.scss$/, loader: 'style!css!sass'}, // => 透過 css-laoder 不只處理編譯好的 css, imports 同時包含 url(...)
      // { test: /\.css$/, loader: 'raw!sass' }, // => 回傳編譯好的 css 程式碼單純只解析 imports 但不處理 url(...)
      { test: /\.(png|jpg)$/, loader: 'url?limit=8192' } // 當檔案小於 8K 的時候會產生 base64 格式的 dataURI 超過的話則直接帶連結
    ]
  },
  resolve: {
    extensions: ['', '.js', '.json', '.coffee', '.scss', '.css']
  },
  plugins: [definePlugin, commonsPlugin],
}

事實上 webpack 檢查的就只是重複 require 的部分,當多個 entrypoint 都有使用到某個模組,就可以透過上面的方式提出。
如此一來在 html 則要加入 <script src="build/common.js"> 否則會爆。這麼做就可以享受瀏覽器為我們快取檔案的優點。

9. 非同步載入

CommonJS 標準屬於同步的處理方式但是 webpack 提供了一種方式來達到非同步處理相依性載入
這通常對於 client 端有使用路由的狀況非常實用,假設您透過路由來取得的每個頁面,但是您不希望直接就下載所有程式碼直到程式運行真的需要該部分程式碼的時候才下載。
這個時候我們就可以使用 require.ensure() 的方式來載入模組

下面是範例的程式碼,ensure 的第一個參數是相依的模組,類似於 RequireJS 的 define()

if (window.location.pathname === '/feed') {
  showLoadingState();
  require.ensure([], function() {
    hideLoadingState();
    require('./feed').show(); // 當這個函式被呼叫,模組保證被同步載入可以使用
  });
} else if (window.location.pathname === '/profile') {
  showLoadingState();
  require.ensure([], function() {
    hideLoadingState();
    require('./profile').show();
  });
}

webpack 會幫您處理剩下的事情,產生因為非同步設定而需要額外 chunk 檔案
有點難懂,沒關係我們現在先新增另外一個模組 benz.js

module.exports = "It's from module Benz";

然後在我們的 main.js 放入

if (window.location.pathname === '/profile.html') {
  require.ensure([], function () {
    console.log(require("./benz"));
    document.write(require("./benz"));
  })
}

編譯之後會看到如下圖,官方文件提到的 chunk 實際上就是 webpack 處理過後依照需求區分的程式碼片段

webpack-dev-server

webpack-dev-server 是一個小型的 node.js Express 伺服器,其使用 webpack-dev-middleware 來取得 webpack 封裝的結果。
在運行時也有使用 socket.io 使其可以即時發送編譯後的資訊到客戶端
同時這個開發伺服器也可以根據不同需求使用不同的模式,假設我們採用下面這組設定檔

module.exports = {
  entry: {
    app: ["./app/main.js"]
  },
  output: {
    path: './build',
    publicPath: "/assets/",
    filename: "bundle.js"
  }
}

上面這組設定的意思您現在應該很請楚了,即我們有一隻進入點的程式(檔案)在 app/main.js ,webpack 將會打包 entrypoint 成 bundle.js 檔案到 bundle 目錄。
同時我們也回顧一下光 entry 的設定就多種組合

module.exports = {
  // 1
  entry: {
    app: ["./app/main.js"]
  },
  // 2
  entry: "./app/main.js",
  // 3
  entry: {
    app: "./app/main.js"
  },
  // 4
  entry: ["./a.js", "./b.js"],
}

如果使用陣列的方式設定,所有的模組會在啟動時被載入,而最後一個檔案會被匯出。另外注意到如果適用第四種方式然後在 output 也使用了 [name] 那這個 name 預設是 main
而想要多個 entrypoint 檔案的話則透過物件的格式,webpack 就會產生多個 entrypoint bundle。

預設一般模式(Inline mode)

剛剛我們提到 webpack-dev-server 有不同的模式,現在我們就來瞭解一下其中一個 inline 模式。
一般情況下 webpack-dev-server 會處理當前目錄的檔案(就是你下指令時的那個目錄),除非您有指定 content-base

$ webpack-dev-server --content-base build/

使用了這個設定,webpack-dev-server 就會處理你指定的那個目錄,預設 webpack-dev-server 就會自動監視該目錄下的檔案,當發生改變就會自動重新編譯。
不過這些編譯只會放到記憶體並和 publicPath 的路徑關聯,而不會產生實體檔案。
當 bundle 已經存在在相同路徑時也就是已經產生檔案,記憶體中的會優先使用。
舉上面一開始的設定檔為例,這個 bundle 封裝結果可以透過 localhost:8080/assets/bundle.js 存取

為了測試這個結果我們需要建立一個 html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
  <script src="bundle.js"></script>
</body>
</html>

當啟動 webpack-dev-server 之後,預設我們就可以透過 localhost:8080 來存取網站,而上面的設定檔加上了 publicPath 所以結果網址會是 localhost:8080/assets/

即時更新模式(Hot mode)

透過把專用的 script 加到 index.html,您的專案就會得到 live reload 的功能。

<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
  <!-- It is important that you point to the full url -->
  <script src="http://localhost:8080/webpack-dev-server.js"></script>
  <script src="bundle.js"></script>
</body>
</html>

對了,這個功能當然也需要修改一點點設定

module.exports = {
  entry: {
    app: ["webpack/hot/dev-server", "./app/main.js"]
  },
  output: {
    path: "./build",
    filename: "bundle.js"
  }
};

然後執行指令時要加入 --hot 參數

$ webpack-dev-server --content-base build/ --hot

即時更新模式 + 訊息顯示

當您啟動了 webpack-dev-server 您也可以瀏覽 localhost:8080/webpack-dev-server/ 透過這個連結您不只會看到您的內容,同時上方會有一些訊息提示。
並且如果您採用這個連結,檔案並不需要加入剛剛那支特殊的 webpack-dev-server script
所以一般建議的開發流程我們的 html 不用特地加入 script 然後使用 localhost:8080/webpack-dev-server/ 來觀察其結果。

因為 index.html 大多數的時候不會需要靠 webpack 編譯(除非您改用 jade 或 slim),所以當然不會被列在 watch 的檔案中。

webpack-dev-server 指令與參數

一般來說所有的 webpack 參數同等於 webpack-dev-server 參數,不過 output 除外。當然您也可以透過 --config 來指定設定檔。
下面是一些額外的參數

  • --content-base: 指定專案目錄
  • --quiet: 不要輸出任何資訊到 console
  • --colors: 彩色的輸出資訊
  • --no-info: 去除一些不太必要的資訊
  • --host: 設定 hostname 或 IP
  • --port <number>: 設定 port
  • --inline: 內嵌一個 webpack-dev-server 到封裝裡
  • --hot: 加入 HotModuleReplacementPlugin 並切換到即時更新模式 hot mode ,注意不能加入該 plugins 兩次
  • --https: 啟用 https 協定

上面這些參數都可以加入 webpack.config.js

module.exports = {
    // ... webpack.config.js stuff ...
    devServer: {
        contentBase: "./build",
        noInfo: true, //  --no-info option
        hot: true,
        inline: true
    }
}

深入 loaders

何謂 loaders ?

loaders 就是轉換工具,用來把資源檔也就是我們的 js, css 等等這些模組轉換套用到程式上。它們是 node.js 中執行的函式,將資源檔當作參數取得其中的程式碼,轉換並傳回新的程式碼。舉例來說您可以使用 loaders 來告訴 webpack 如何處理並載入 CoffeeScript 或 JSX

功能

  • loader 可以被串連使用,即把一個資源檔從 A loader 交付給 B loader。講的太難懂,那我們先舉個在 Linux 底下所謂的 pipeline 的例子 ls | grep filename 在 Linux 底下我們可以透過 | 來做 pipeline,其行為就是先執行 ls 指令再把 ls 處理完的結果交給下一個指令。 在上面的例子中 require("!style!css!./style.css"); 就是一樣的意思把 style.css 交給 css-loader 先處理,處理完的結果再交給 style-loader。最終 loader 被預期傳回 Javascript,其他過程中的 loader 則可以傳回任意格式。
  • loader 可以套用同步或者非同步的行為
  • loader 運行在 node.js 環境中,且應該可以做到任何您想要的功能
  • loader 允許加入參數,其格式就像 HTTP 的 querystring 一樣,所以我們可以在設定檔或指令中帶入參數
  • loader 可以針對副檔名或正規表示式來設定要處理的檔案
  • loader 可以透過 npm 來發佈或安裝
  • 除了正常 package.jsonmain,一般模組就我們在寫 JS 的 module.exports 也可以匯出 loader
  • loader 可以存取設定檔
  • 擴充套件可以賦予 loader 更多功能
  • loader 可以散播額外的任意檔案
  • 其他

如果您對其他 loader 範例有興趣可以參考列表

解析 loader

loader 被解析的方式類似於模組。一個 loader 模組一般來說會需要輸出一個 function,且與 node.js 相容的 Javascript。在大部份的情況下我們透過 npm 來管理 loader
不過您也可以將 loader 當作程式中的檔案來處理

參考(匯入) loader

雖然這不是強制的,但依照慣例 loader 通常命名微 xxx-loader,而 xxx 就是其功能與描述的名稱也是我們在 pipeline 使用的名稱。例如 json-loader
您也許會用完整名稱來引用該 loader(json-loader) 或者透過縮寫即 json
關於 loader 命名慣例和搜尋的優先順序被定義在 webpack 設定檔的 resolveLoader.moduleTemplates

安裝

如果 loader 存在,通常我們會直接透過 npm 安裝

$ npm install xxx-loader --save
$ npm install xxx-loader --save-dev

使用方式

在您的專案中可以使用不同的方式來使用 loader

  • 明確的寫在 require
  • 在設定檔指定
  • 直接透過指令參數設定

明確的寫在 require

注意: 盡量避免使用這種方式,而採用設定檔的慣例來設定 loader
這種方式是透過在 require 語句(或者 define, require.ensure) 中指定會採用的 loaders。透過 ! 將 loader 區隔。
此時會相對於當前目錄去解析路徑

require("./loader!./dir/file.txt");
// => 使用在該目錄下的 loader.js 檔案來轉換 dir/file.txt 檔案

require("jade!./template.jade");
// => 使用 jade-loader (npm 安裝的模組) 來轉換 template.jade

require("!style!css!less!bootstrap/less/bootstrap.less");
// => 將 bootstrap/less/bootstrap.less 檔案透過 less-loader 先轉換成 css 再將結果傳給 css-loader 最後傳給 style-loader

在設定檔中指定

您也可以透過正規表示式 RegExp 來設定

{
  module: {
    loaders: [
      { test: /\.jade$/, loader: "jade" },
      // => jade loader 被用來處理 .jade 檔案
      { test: /\.css$/, loader: "style!css" },
      // => style-loader 和 css-loader 被用來處理 .css 檔案
      // => 下面是等價另一種格式的寫法
      { test: /\.css$/, loader: ["style", "css"] },
    ]
  }
}

指令

當然您也可以透過指令參數來設定對應的 loaders

$ webpack --module-bind jade --module-bind 'css=style!css'

上面的指令會讓 jade-loader 對應處理 .jade 檔案,然後 style-loader 和 css-loader 針對 css 檔案

loader 參數(Query parameters)

loader 可以透過在設定傳入參數,格式類似網址的 query string。這個參數只要在該 loader 後面加上 ? 舉例來說 url-loader?mimetype=image/png

注意: 至於 query string 的格式則是由 loader 來決定。通常會在該 loader 的文件上說明。大部份的 loader 參數的格式會是 ?key=value&hey=hi 或者 ?{"key": "value", "key2": "value2"}
整個設定寫起來會如下

require("url-loader?mimetype=image/png!./file.png");

用在設定檔中

{ test: /\.png$/, loader: "url-loader?mimetype=image/png" }

或者

{
    test: /\.png$/,
    loader: "url-loader",
    query: { mimetype: "image/png" }
}

使用擴充套件

擴充套件就是文件上說的 plugins,它是透過 webpack.config.js 的 plugins 屬性來載入模組之中,如下面設定

var webpack = require("webpack");

module.exports = {
    plugins: [
        new webpack.ResolverPlugin([
            new webpack.ResolverPlugin.DirectoryDescriptionFilePlugin("bower.json", ["main"])
        ], ["normal", "loader"])
    ]
};

其他擴充套件

如果不是內建的 plugins 則通常需要透過 npm 來安裝,裝完之後照下面這樣使用即可

var ComponentPlugin = require("component-webpack-plugin");
module.exports = {
    plugins: [
        new ComponentPlugin()
    ]
}

總結

經過一輪練習之後,如果您要使用 webpack-dev-server 在設定上路徑會是比較需要注意的地方,然後就是記得 webpack-dev-server 的輸出會放在記憶體。
雖然現在官方文件比較沒有詳細的範例,不過您還是可以找到其他
希望在這篇之後各位能夠對這個起手式有些認識也應該都具有使用 webpack 的基礎能力。

示範多檔編譯
Webpack + React 中文
Webpack with Rails
react-hot-loader
深入了解 webpack plugins

各種情況的生命週期流程

大致上元件執行生命週期方法的情形可分為四種

  1. 初始化,第一次 render
    • getDefaultProps()
    • getInitialState()
    • componentWillMount()
    • render()
    • componentDidMount()
  2. props 發生改變時
    • componentWillReceiveProps(nextProps)
    • shouldComponentUpdate(nextProps, nextState)
    • componentWillUpdate(nextProps, nextState)
    • render()
    • componentDidUpdate(prevProps, prevState)
  3. state 發生改變時
    • shouldComponentUpdate(nextProps, nextState)
    • componentWillUpdate(nextProps, nextState)
    • render()
    • componentDidUpdate(prevProps, prevState)
  4. 元件 unmount 卸載時
    • componentWillUnmount()
  • 當遇到複合式元件的狀況時,子元件的生命週期從父元件 render 之後開始發動
  • 一旦父元件發生改變,子元件的 componentWillReceiveProps 還是會觸發。也就是每次父元件更新,子元件都會重新渲染(Update流程)
  • 當使用 React.createClass 建立一個 component 時, render 是生命週期方法中必須的。
  • 當 render 被呼叫時會去檢查 this.props, this.state 並且只能回傳一個 component 意即其他元素都要包在這個元件底下。
  • render return null 或者 false 則表示不渲染輸出,背地裡 React 則是輸出 <noscript> 標籤。
  • 不可以在 render 裡面操作修改 state 和 props。
  • getInitialState 一定要回傳物件或 null,在元件被 mounted 之前僅被呼叫一次。
  • getDefaultProps 如果回傳一個物件,會和所有元件的實例物件共用,傳址不是傳值。

第一次初始化的流程

  1. displayName 用來設定元件名稱
  2. getDefaultProps() 當元件類別被建立時就會觸發,且和所有實例物件共享
  3. getInitialState() 開始渲染元件時初始化
  4. componentWillMount() 準備掛載 DOM 之前觸發
  5. render() 執行輸出
    • 如果裡面有其他的子元件則依據上面的流程 getInitialState() -> componentWillMount() -> render() -> componentDidMount()
  6. componentDidMount() DOM 完成之後立即觸發(會等到子元件都完成才觸發)

更新的流程

  1. componentWillReceiveProps(nextProps) 這邊使用 setState 不會再觸發一次 render。初始化不會被觸發
  2. shouldComponentUpdate(nextProps, nextState) 元件是否要更新
  3. componentWillUpdate(nextProps, nextState) 將要更新之前,不可以用 setState
  4. render()
  5. componentDidUpdate(prevProps, prevState)

呼叫 setState 不會 re-render 的方法

  • componentWillMount()
  • componentWillReceiveProps()

存取 DOM 的適當時機

  • componentDidMount()

不得使用 setState 的事件

  • shouldComponentUpdate(nextProps, nextState)
  • componentWillUpdate(nextProps, nextState)
  • render()

在這篇文章中我們將要探討如何使用 ES7 的新功能.

ES6 新增了一個簡單更具可讀性的語法讓我們可以建立類別(class). 搭配 ES6 匯入匯出模組的語法讓我們的程式更加清楚易懂.
而 Decorators 讓我們可以在設計時期透過註記的方式修改類別與屬性.
在 ES5 物件實字(Object Literal)支援可以使用任意的表達式(Expression)
而 ES6 類別單純只支援使用函式表達式或稱作函式常量(Function Literal)
現在 Decorator 讓 JS 具備了可維護性與可讀性的宣告式語法

Object Literal 是一個透過 , 逗號分隔的鍵值對列表, 再透過一個大括號包起來.

// object literal 又稱物件實字
let obj = {
  name: 'andyyou',
  age: 28,
}

Function Literal 函式表達式或稱作函式常量 - 即定義一個不具名的 function, 單純觀察下面的範例會覺得其類似於一個 function 得宣告片段, 除了語法看起來像一段表達式和並沒有宣告函式名稱

// Function Literal
var func = function () {
  console.log('I am a function');
}

// 具名 Function
function F() {
  console.log('I am a function');
}

簡單說就是我們會對 classproperty 使用 decorator

何謂 Decorators?

如果你不熟悉 Decorator, 他們類似於一種標記或描述資料(metadata), 不過不同的是它們會被附加套用在類別, 方法, 或者屬性上.
概念上就是你可以透過附加的方式來操作定義. 這麼說非常抽象讓我們看看範例:

// 可否測試
function Testable(target) {
  target.isTestable = true
}

@Testable
class OurClass {

}

// 接著我們就可以確認這個類別是否可以測試
OurClass.isTestable // => true

上面那個 @Testbale 就是 decorator 語法, 我們用白話文來說就是透過像加註解或標記的方式讓類別或屬性增加其他功能.
如果我們想要傳入參數也就是一個動態的註記函式, 我們可以透過 factory pattern

function testable(isTestable) {
  return function(target) {
    target.isTestable = isTestable
  }
}

@testable(true)
class OurClass {

}

console.log("OurClass: ", OurClass.isTestable); // => OurClass: true

小結來說一個 decorator

  • 一個表達式
  • 一個 function
  • 可以在其參數中取得目 target, name, property, descriptor
  • 選擇性的回傳一個 descriptor 用來安裝在目標物件上

透過宣告的方式加入 Mixins

在程式語言的概念中 Mixin 是我真的覺得好用的功能(事實上在這裡它不叫 Mixin 稱為 traits)
因為在大多的開發過程我們只是要把一些功能, 方法抽出來重複使用, 實務上常遇到不好決定到底該歸納到什麼類別. 如果你要說介面(Interface)那又扯遠了

簡單的說 mixin 就是把一些功能抽出去獨立一個模組, 根據不同語言其本質可能是另一個類別或函式等等.
所以我們需要的其實就是如何把這些函式或者方法抽出去和合併的方式.
在 ES5 時我們可以使用 Object.assign 來合併(merge) prototype, 背地裡其實是 Polyfill 例如使用 underscore 或 lodash 的 _.extend 實作.

$ npm i -S object.assign
var assign = require('object.assign');
function CarAbility() {};

CarAbility.prototype.run = function () {
  console.log('Car is running');
}

function ToyotaCar() {

}

assign(ToyotaCar.prototype, CarAbility.prototype);

var car = new ToyotaCar();
car.run(); // => Car is running

而在 ES6 當我們透過新的語法 class 來定義類別的時候, 我們並不能簡單的使用 prototype 當做 mixin. 且預設所有的方法(Medthod)並不是 enummerable 這是 JS 中物件底層的屬性
簡單的說就是 for in 會不會將其列舉出來. Object.assign 只會合併那些物件中 enummerable 的方法
此時我們可以改用物件來作為 mixin 接著透過 Object.assign 將其套用到目標類別上

const CarAbility = {
  run() {
    console.log('foo')
  }
}

class BMWCar {

}

Object.assign(BMWCar.prototype, CarAbility)

let car = new BMWCar();
car.run();

這是一種指令式的風格, 那如果我們可以在宣告類別的時候合併呢? 讓我們來建立一個簡單的 decorator 來完成這個需求

export function mixins(...list) {
  return function (target) {
    Object.assign(target.prototype, ...list)
  }
}

然後我們就可以使用 ES6 的匯入語法

注意從這邊開始因為使用了 ES6 的語法所以你會需要 Babel 來編譯

import {mixins} from './mixins'

const CarAbility = {
  run() {
    console.log('I am running');
  }
}

@mixins(CarAbility)
class AudiCar {

}

let car = new AudiCar();
car.run();

Traits

既然我們已經可以透過上面的方式實作 mixin, 那為什麼還要有個 Traits, 原因是有些情況您需要更多的控制權, 當你只想合併想要的功能時, 或者遇到不同物件卻有相同名稱的函式的狀況.
Traits 讓我們可以避免合併時的名稱衝突, 我們可以排除方法或者是透過 alias 別名的方式改變方法的名稱

關於實作面, 我們可以透過CocktailJS這個函式庫輕鬆實作 Traits, 這個函式庫包含了 annotations, traits 以及讓 class 具備更多陳述式的用法(或者說從外部合併屬性或方法的寫法)

這裡為了單純只說明觀念我們只使用traits-decorator, 它是一個實驗 decorator 和 bind-operators 的函式庫

import {traits} from 'traits-decorator';

// 使用 class 為 traits
class CarTraitClass {
  run () {
    console.log('I am running');
  }
}

// 使用 object 為 traits

const CarTraitObject =  {
  start () {
    console.log('The car is started');
  }
}

@traits(CarTraitClass, CarTraitObject)
class HondaCar {

}

let car = new HondaCar();
car.run(); // => I am running
car.start(); // => The car is started

再次提醒如果你用 babel 實作需注意目前 @ 語法要啟用 experimentalstage 0

$ babel [source].js -o [destination].js --stage 0

名稱衝突

剛剛上面有提到關於衝突的部分, 假設我們現在遭遇到 traits 甚至是類別本身的方法名稱產生衝突的狀況, 就是名字一樣

import {traits} from 'traits-decorator';

// 使用 class 為 traits
class CarTraitClass {
  run () {
    console.log('[class] I am running');
  }
}

// 使用 object 為 traits

const CarTraitObject =  {
  run () {
    console.log('[object] I am running');
  }
}

@traits(CarTraitClass, CarTraitObject)
class HondaCar {

}

let car = new HondaCar();
car.run();

重新編譯在執行會產生錯誤(編譯時不會出錯)

throw new Error('Method named: ' + methodName + ' is defined twice.');

很明顯的是因為我們的 run 定義了 2 次, 此時 Traits 讓開發者負責去解決這個衝突, 這部分和 mixin 不太一樣, mixin 的話會讓後面載入的方法覆寫掉前面的.

為了解決這個問題我可以排除我們不想要的 method 或者為其創造一個別名

import {traits, excludes} from 'traits-decorator';

// 使用 class 為 traits
class CarTraitClass {
  run () {
    console.log('[class] I am running');
  }
}

// 使用 object 為 traits

const CarTraitObject =  {
  run () {
    console.log('[object] I am running');
  }
}

// 別名的用法
// @traits(CarTraitClass, CarTraitObject::alias('drive'))
@traits(CarTraitClass, CarTraitObject::excludes('run'))
class HondaCar {

}

let car = new HondaCar();
car.run(); // => [class] I am running

您可能注意到一個奇怪的小東西 :: 這是 bind-operator 基本上 bind operator 就是 .bind() 的縮寫, ::this.method = this.method.bind(this)
::Car.run 會等於 Car.run.bind(Car)

::@ 這些語法在 Babel 都還處於實驗階段, 所以如果您要使用就必須要自己設定開啟 stage 0

總結

透過這種方式我們說我們可以在設計時期就可能是類別已經寫好了的情況下透過註記來增加功能, 不要用的時候也可以輕易移除.

實用的 Javascript debug 技巧

快速找到 DOM 元素

當我們使用 Chrome 的開發者工具(inspector)選取元素(Elements Panel)功能時 Chrome 會保留最後 5 個被我們選到的元素
此時只要透過 $0 - $4 就可以找出剛剛選取的元素 $0 是最新的以此類推

將物件資訊輸出成一個表格

使用 console.table 來輸出物件陣列。舉例如下

var animals = [
 { animal: 'Horse', name: 'Henry', age: 43 },
 { animal: 'Dog', name: 'Fred', age: 13 },
 { animal: 'Cat', name: 'Frodo', age: 18 }
];
console.table(animals);

追蹤 function 的堆疊

當程式碼越來越多越來越複雜時, 我們想知道到底是誰觸發了某個 function 就會變得異常困難. 由於 js 並不是靜態結構化的語言, 很多時候很難判斷過程中到底發生了什麼事. 這種時候使用 console.trace 會讓我們很方便地得知這些訊息

最後在 console 我們會由下而上知道其呼叫的關係

快速 debug function

假設你想要在一個 function 中設定中斷點, 比較常用的兩種方式

  • 在 inspector 加入中斷點
  • 在程式碼中加入 debugger

另外這邊要提到的方法是透過在 console 使用 debug 方法
直接使用 debug(function_name) 當執行到該函式時就會停止

把不需要 debug 的部分加入 Black box

監視指定的 function

在 console 面板使用 monitor(function_name) 觀察其帶入的參數

快速選取到元素

使用 $('css-selector') 會回傳第一個符合的元素, $$('css-selector') 回傳全部

何謂 jspm ?

jspm (javascript package manager) 號稱是完全支援(無摩擦)瀏覽器載入的套件管理工具

  • jspm 也是一套套件管理工具,採用 SystemJS 來處理模組載入 當然內建也就支援動態的 ES6 模組載入
  • 可以直接從 npm, Github 載入任何模組標準所寫得模組程式(ES6, AMD, CommonJS, global) 任何自訂的registry例如 npm 也可以透過 Registry API 來註冊連結
  • 開發時,會將 ES6 檔案和編譯後的 plugins 分開載入
  • 產品上線時,優化成一個 bundle ,也可透過指令將 bundle 分層或單獨執行

入門

  • 安裝 jspm 指令
  • 建立專案
  • 初始化專案的設定檔
  • 安裝來自任何 registry 的套件
安裝 jspm 指令
$ npm install jspm -g
建立專案
$ cd my-project
$ npm install jspm --save-dev

上面這個步驟不是必須的,但官方建議在專案安裝一個屬於專案自己的 jspm 版本,這可以確保當系統更新的時候專案的 jspm 版本並不會被影響,在目錄底下執行 jspm -v 可以顯示該專案的版本。

此時我們沒有 package.json 所以下 --save-dev 不會把設定加到 package.json 中。

初始化專案的設定檔
$ jspm init
Package.json file does not exist, create it? [yes]: 
Would you like jspm to prefix the jspm package.json properties under jspm? [yes]: 
Enter server baseURL (public folder path) [.]: 
Enter jspm packages folder [./jspm_packages]: 
Enter config file path [./config.js]: 
Configuration file config.js doesn't exist, create it? [yes]:
Enter client baseURL (public folder URL) [/]: 
Which ES6 transpiler would you like to use, Traceur or Babel? [traceur]:

這個指令會協助我們設定 package.jsonconfig.js(jspm 預設)。注意到當目錄為空的時候 jspm init 會試圖自動幫我們處理下面這些項目

  • baseURL: 這個設定指的是相對於在 server 上的 public folder 通常是 /,也就是 package.json 應該放置的專案根目錄,或者說當網址為根的時候該如何對應到此專案目錄。
  • jspm packages 目錄: jspm 會將其他相依的檔案安裝在這裡。
  • config 檔案路徑: 這個是 jspm 的設定檔也應該要跟 package.json 一樣放在專案的根目錄
  • client baseURL: 這個 URL 設定瀏覽器如何存取被託管在 server 上的目錄
  • transpiler: 設定使用的 compile to js language(ES6+ to ES5)。可以在任何時間透過 jspm dl-loader --babel 來修改這個選項。也可以直接在 jspm 的設定檔中透過 babelOptionstraceurOptions 修改。

如果你需要重新設定這些屬性,可以直接修改 package.json 接著執行 jspm installjspm init 來更新

而如果想要重新發動 jspm 的詢問來更新檔案可以執行 jspm init -p

安裝來自任何 registry 的套件 例如: Github, npm ...

首先 registry 的意義是一個可以註冊的地方或空間。對應到程式開發領域的話指的就是像 Github, npm, gem Nuget, apt, yum 。開發者可以把自己的程式(函式庫)註冊並上傳發佈的套件管理站點或系統

透過下面的指令可以從任何 registry 安裝

$ jspm install npm:lodash-node
$ jspm install github:components/jquery
$ jspm install jquery
$ jspm install myname=npm:underscore
$ jspm install [registry]:[package name]

多個套件安裝可以在同一個指令用空白隔開,上面的例子我們看到了無論是 npm 或 github 都可以透過這種方式安裝。

大部份的 npm 套件安裝不需要再加上額外的設定,這是因為 npm 站點使用專案設定規範適用於所有 Node 和 npm-style 的程式碼,也因此相容 jspm

Github 的套件或說程式碼就可能需要對 jspm 補些設定

所有安裝項目的設定會存放在 package.json,而 jspm_packages 目錄和設定檔可以透過執行 jspm install 全部重建,所有相依的第三方元件仍然是透過 package.json 來紀錄設定。

原則上你不該把這些第三方的程式碼一起加入版控。

簡單來說,jspm 是透過 system.js 來處理載入模組這件事。而載入模組這件事為什麼需要處理,是因為在 js 的世界裡存在較多的標準,ES6+ 的標準又還沒普及,所以通用的載入器需求就產生了,白話文即不管你是用 CommonJS 或 AMD 甚至 ES6 寫的東西都要能夠被載入,但 jspm 並不是唯一可以處理這個問題的工具。

一個 nodejs 專案是透過 package.json 來管理相依的函式庫或套件,而 jspm 主要也是透過 package.json 來組織其設定,它會根據解析 package.json 的結果在 config.js 裡加上 system.js 以及自己需要的設定。
也因此 config.js 只要透過 jspm install 就可以根據 package.json 的資料重建設定檔。

搞懂 npm, jspm, webpack 的使用上的差異

我們依照模組標準開發出一個函式庫模組並丟到 npm 給大家用,npm 這個詞意義上又分成 npm-cli 指令和 npm 這個集合套件的站點,所以這邊我們提的 npm 指的是指令的部分,例如 npm install 這樣的指令來處理安裝,移除,管理的指令。

而 webpack 或 browserfiy 它們是處理模組封裝和載入的工具,意思是把你寫的 js 打包,大略的實作行為就是你在程式中用 require 來載入其他檔案最後輸出一隻打包的 js,所以安裝的部分還是用 npm ,而載入則由 webpack 這類的工具負責。

那 jspm 呢?第一個不同點就是除了你可以透過 npm 安裝專案專屬 jspm 外(用來鎖定版本),其他套件或函式庫你都是用 jspm install [library name] 的方式來安裝。
那設定呢?基本上上面就提到了; 如果你是採用 node 或 npm-style 的函式庫或模組是不用設定的,因為 jspm 會幫你把設定加到 config.js 裡面。

下面整理 jspm v.s npm + webpack 的流程順序

step 1. jspm 下載模組並安裝 (使用 npm 下載並安裝)
step 2. 在 package.json 中紀錄設定,而程式碼安裝到 jspm_packages (npm 一樣把設定記錄到 package.json 檔案裝到 node_modules)
step 3. 分析 package.json 後在 config.js 產生設定 (使用 webpack 自己手動寫設定)
step 4. 撰寫程式碼,載入並應用模組
step 5. 執行 jspm bundle (執行 webpack ./main.js ./bundle.js)

實作

現在我們就可以開始在程式中撰寫載入的部分了。為了更加明白其運作。這次的練習會包含

  • 建立一個 jspm 專案
  • 先試著寫 ES6 語法來測試
  • 安裝 jsx, react
  • 撰寫一個簡單的 React Component
  • bundle & 優化
$ mkdir jspm_new_project
> 建立一個空的目錄

$ jspm init
> 初始化專案,注意 transpiler 選 babel

$ jspm install jquery
> 試著安裝 jQuery, 我們先試著用基本的 jQuery 搭配 ES6 寫點範例

$ mkdir -p assets/js/lib
$ mkdir -p assets/js/components
> 模擬真實狀況組織 js 目錄

  • 建立 assets/js/lib/Cat.js
export default class Cat {
  constructor (name) {
    this.name = name;
  }

  yell () {
    var result = `${this.name}: meow`;
    console.log(result);
    return result;
  }
}
  • 建立 assets/js/main.js 來載入我們的小貓類別
import $ from 'jquery';
import Cat from './lib/Cat';

var cat = new Cat("Mily");

$(function() {
  $(".animal").text(cat.yell());
});
  • 建立 index.html 來使用 main.js,伺服器部分先用簡單的 python -m SimpleHTTPServer 起一個 server 來測試 當然如果你會其他方式例如使用 express 或者架設 apache 等也可以
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>jspm sample</title>
  <script src="jspm_packages/system.js"></script>
  <script src="config.js"></script>
</head>
<body>

  <span class="animal"></span>
  <script>
    System.import('assets/js/main');
  </script>
</body>
</html>
  • 開啟瀏覽器輸入 http://localhost:8000/

第一階段我們驗證了 ES6 語法和 jQuery 運作相當正常,接著我們要來試著寫一個 React Component 試試。
首先要注意的是 jspm 預設只會載入 .js 副檔名,而且只處理純 js 即使我們知道 Babel 能處理 JSX 但如果你直接照著寫是會出錯誤的。
要解決這個問題我們需要 jsx loader plugin 因此我們需要先安裝 jsx 和 react

$ jspm install jsx react
  • assets/js/components/ 建立一個 Dog.jsx
import React from 'react';

class Dog extends React.Component {
  constructor(props) {
    super(props);
  }

  render() {
    return (
      <div>{this.props.name}: bark!!!</div>
    );
  }
}

export default Dog;

回到 main.js 要注意上面說過 jspm 預設不處理 jsx ,而且就算你把副檔名換成 .js 還是會出錯,正確的用法是只要有用到 jsx 語法的檔案副檔名都應該是 .jsx
接著在 import 的時候要記得副檔名和 ! 如下面範例 import Dog from './components/Dog.jsx!'

對了!因為 main.js 也要用 jsx 語法所以記得將其副檔名也換掉喔,index.html 裡面的 System.import 也要加入特殊語法。

import $ from 'jquery';
import React from 'react';

import Cat from './lib/Cat';
import Dog from './components/Dog.jsx!'

var cat = new Cat("Mily");

$(function() {
  $(".animal").text(cat.yell());
});

React.render(<Dog name="Wally"/>, document.getElementById("dog"));
/* 警告: Warning: `require("react").render` is deprecated. Please use `require("react-dom").render` instead.*/
  • 修改 index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>jspm sample</title>
  <script src="jspm_packages/system.js"></script>
  <script src="config.js"></script>
</head>
<body>

  <span class="animal"></span>
  <span id="dog"></span>
  <script>
    System.import('assets/js/main.jsx!');
  </script>
</body>
</html>

至此我們已經玩完一輪基本的使用方式了 - 完整範例
上面示範的方式是在 html 頁面加上 SystemJS loader 和 config.js

基本上我們需要啟動一個 server 才能存取,不過您也可以透過下面這種方式直接存取檔案

  • Mac 的 Chrome 使用者可以執行 /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --allow-file-access-from-files &> /dev/null &
  • Firefox 先切換到 about:config, 搜尋 security.fileuri.strict_origin_policy 把值改成 false

這個時候 config.js 的 baseURL 就要設成 . 這樣才能正常運作

最佳化
$ jspm bundle assets/js/main --inject
# 一般 JS


$ jspm bundle assets/js/main.jsx! --inject
# JSX 的指令格式

之後 system.js 就會自動取得這個 bundle.js

另外還可以使用 jspm bundle-sfx assets/js/main 來建立一個獨立的 bundle script 如此一來就不用在 html 中使用 config.jssystem.js

結論

總結來說 jspm 試圖從安裝函式庫到封裝全部一起處理,且盡可能不需要你去設定。整體用起來到還是蠻簡潔的。
要用類似 webpack-dev-server 的可以參考 jspm-server。似乎 webpack 有的東西都在慢慢補齊中。
不過以 React 的立場來看 webpack 的資源和參考還是多一點。

因為前端工具更新的速度實在太快了,這邊想對目前用過的東西做點簡單的總結,好讓各位看官對於工具的選擇上能夠有更清楚的概念

Grunt gulp 本質上是任務管理工具
  • 所以我們可以組織打包,測試,任何想做的事等於幫我們建立一堆指令
webpack, browserify, RequireJs 處理模組與相依性管理
  • 所以只要在設定檔設定哪個檔案類型該怎麼處理(封裝編譯),它就會幫你處理編譯模組以及模組載入
  • Grunt, gulp 和 webpack 這類工具處理問題的角度不同,一個是讓你組織各種任務,一個是針對檔案類型處理如何編譯,壓縮,封裝,載入
npm 本質上為模組(函式庫)的管理工具 + 簡易的任務工具
  • 安裝,移除,顯示資訊。
  • 透過 package.json 執行寫在裡面的 script
jspm 概念上為 npm + webpack
  • 因為除了是一個模組(函式庫)的管理工具外還使用 System.js 處理模組載入的部分

資源

OOCSS 的兩個核心觀念

  • 分離結構(html tag 結構)與樣式(ui 的樣式) Separate structure and skin
  • 分離容器(layout 佈局)與內容(直接包 content 的 tag) Separate container and content 最後達到重複使用樣式的最高原則

搭配 BEM 的歸納邏輯

如果套上 BEM 的規則那就是

structure -> container (block)
          -> content (block | element)
skin -> modifier
  1. 先找出相同結構的 html tag structure 以 blog 系統為例 列表頁有文章的 meta-data, 文章頁也有 meta-data, html 的結構一樣但視覺 ui 長的不同。這些結構就可以定義一個 object 按照 OOP 的觀念但實際上只是 html 和 css 的組成

這邊有一點要強調不管是 BEM 或 OOCSS 甚至其他方法都提到不要讓樣式相依於結構。舉例如下

<div class="navbar">
  <span class="navbar-button">Logo</button>
</div>
.navbar > span { /.../ } // 錯誤:這樣樣式就會相依於結構,一定要 .navbar 搭配子項目 span 才會成立
/* 另外也不要直接用 tag 選擇器,會導致對元素依賴,實際的例子即 bootstrap 的 .btn 可以套用到 a, button 上 */

.navbar-button { /.../ } // 此時應該獨立出一個 class name 針對該元素

第一步驟的重點在於找出可重複使用的 html 結構,概念上我們認為這個東西可以抽象化為一個物件

  1. 找出結構後,再來分析誰是容器, 誰是內容,透過這樣的明確的定義與分類讓我們的 css 容易維護與增加新功能而不會影響舊有的程式碼。舉上面 meta-data 的例子,這是開發時很常見的狀況
/*post中的meta-data*/
<div class="post">
  <p class=”metadata”>
    <a>Author name</a>commented on<a>21-02-2010</a>@
  </p>
</div>
/*comment中的meta-data*/
<div class="comment">
  <p class=”metadata”>
    <a>Author name</a>commented on<a>21-02-2010</a>@
  </p>
</div>
/*userinfo中的meta-data*/
<div class="user-info">
  <p class=”metadata”>
    <a>Author name</a>commented on<a>21-02-2010</a>@
  </p>
</div>

接著我們就會直覺得這樣做

.post .metadata {css code}
.comment .metadata {css code}
.userInfo .metadata {css code}

一旦這麼做 meta-data 就會相依於容器,我們應該使用擴展的方式來做,結構只下基礎通用的樣式,而同樣結構不同樣式的部分我們透過擴展 class name 來做。這個時候就會建議使用 BEM 的命名原則 - 所以我們可以總結容器內容在 OOCSS 裡都是屬於一種物件

.metadata--post {css code}
.metadata--comment {css code}
.metadata--user-info {css code}

在使用 OOCSS 的過程中很容易掉入表面外觀語意的陷阱,例如 col-left, col-middle, bg-gray, text-border 這樣的命名。記得保持一種重點堅持以邏輯和語意來給元素命名,不要因為懶惰而隨意命名,例如 bg-red 應該用 bg-danger

  1. 從 code 方面著手,對 code 重構,找出重複的 css rules, 通常會是像設定 background 等針對外觀,也就是說把 skin 抽出來。

重點回顧與注意事項

  • Separate structure and skin 分離結構(html tag 結構)與樣式(ui 的樣式)
  • Separate container and content 分離容器(layout 佈局)與內容(直接包 content 的 tag)
  • 讓 css 不要具有相依性也是程式中所謂的低耦合的概念,透過 BEM + OOCSS 的拆分與命名原則可以達到
  • 注意不要掉入表面外觀語意的陷阱,堅持以邏輯和語意來給元素命名

總結

簡單的來說,OOCSS 即透過兩個主要的原則將我們的 css 分類與抽象化成物件,兩個原則分別為 Separate structure and skinSeparate container and content,接著實作的第一步先找出重複的結構就是 html ,再依據其周遭元素的關係分類成容器 container內容 content 再依據不同頁面或者同結構不同長相的部分來做 skin。我們定義好的這一些 class name 就好比是一個一個的物件。

過程中低耦合,即減少選擇器的相依性是主要的重點。如此才能達到重複使用也不會產生改一個爆一個的狀況。
低耦合的重點:

  • 單純用 class name 選擇器,不要使用 tag, id 等,也不要從物件定義的外部來影響樣式。不使用 tag 意味著也不會限制使用的元素,class name 應盡可能讓所有元素都能套用。
  • 堅持以邏輯和語意來給元素命名,搭配使用 BEM 實作起來更有規則
  • 先下通用的基礎樣式,再使用擴展的方式來增加不同的 skin(modifier)

最後搭配 BEM 的圖示讓我們快速記住關係

structure -> container (block)
          -> content (block | element)
skin -> modifier

參考資源

OOCSS觀念篇

路人甲:Hey! 我想要學習寫那種跟類似桌面應用程式一樣具有高度互動性及酷炫 UIUX 的網站,啊!聽說叫 RIA,我聽說您已經在那個領域很有經驗。
路人乙:沒錯,我就是一個前端攻城獅。而且我用過蠻多新的工具和技術的。
路人甲:酷斃了!我現在想要用 HTML, CSS 和 Javascript 做一個簡單的待辦清單網站,然後我想說要用 jQuery ,用 jQuery 可以嗎?
路人乙:嗯... 不行,那個東西已經太舊了。jQuery 差不多沒人在用了,現在你需要使用 React,那才是趨勢。
路人甲:喔喔!好啊!不過那是啥?
路人乙:針對建置網站的部分 React 是一種新的方式。使用了 Virtual DOM 來實作底層並且讓我們可以用 JSX 來寫
路人甲:虛擬...?JSX?那些又是啥米
路人乙:JSX 您可以把它想成強化版的 HTML ,它擴充 Javascript 的功能讓你可以在 JS 中撰寫 XML。而 Virtal DOM 用一個特殊的樹狀結構物件來表示 DOM 並且讓我們可以直接透過他們來操作實際的 DOM ,效能超級好
路人甲:在 JS 中撰寫 XML 是什麼意思?
路人乙:聽著!React 就是未來。它讓您可以建立元件並且重複使用
路人甲:喔~你的意思是說就像 Backbone 的 view?
路人乙:不是!Backbone 已經沒人用了,現在所有的東西都要變成元件才行
路人甲:所以我不需要懂 JSX 或 Virtual DOM?
路人乙:不是啦!你還是要學底層的觀念,然後你就可以不用擔心自己操作 DOM 而產生效能不好的問題,元件自己會根據狀態去更新 DOM
路人甲:ㄜ!你搞得我好亂啊。意思是說有一個叫做 React 的新技術可以用來建置元件。那我可以搭配 jQuery 嗎?
路人乙:嗯... React 只幫助我們處理程式中的一部分,剩下的東西你可以自己搭配。不過我剛剛已經跟你說了 jQuery 已經過氣了。喔對了!你可能會需要使用 webpack 來打包封裝你的元件
路人甲:嗯!那是啥
路人乙:簡單說它是一個模組的封裝打包工具。正常情況下你的程式依據模組的原則分成很多檔案達到關注點分離,接著你會希望將他們彙整成單一檔案或者是載入客戶端中的程式碼片段。同時你甚至不需要 react-tools 就是官方提供的那套,你只需要使用 babel 來編譯 JSX
路人甲:Babel ?
路人乙:沒錯!Babel。這是一個很屌的工具可以幫助我們轉換 ES6+ 和 JSX 的程式碼變成可以執行的 ES5 ,當然它也支援 source map,它已經廣泛地被很多人採用甚至 Facebook 也是用它
路人甲:ES6+ ?
路人乙:ES6+ 或稱 ES2015 是下一版的 EcmaScript 讓 Javascript 具備更多新的功能例如:類別 class,新的陣列函式,maps,sets 等等。幾乎所有的人現在都用 ES6+ 來寫他們的程式了
路人甲:很強大嗎?
路人乙:當然啊,ES6 就是未來的趨勢啊!
路人甲:那我要怎樣才能用它
路人乙:首先你需要安裝 NodeJS....
路人甲:安裝 NodeJS?不是吧...你剛剛才說了 React 和元件
路人乙:是的!所以你現在也可以用 Flux 架構來建立程式,遵循它的規則建立 actions, stores, 加上元件
路人甲:Flux?
路人乙:簡單說就是透過 Flux 你的程式會被分成 stores, actions, views 搭配單一方向的資料流原則。views 透過 dispatcher 驅動 actions ,stores 監聽 dispatcher 然後發動事件同時也讓那些監聽同一事件的 view 一起觸發事件
路人甲:嗯...像 MVC 嗎?
路人乙:不是啦 MVC 已經是過去式了,Flux 才是未來。外面已經有很多 Flux 的實作了
路人甲:什麼?Flux 實作?
路人乙:對啊!Flux 只是一個設計模式。你去查 #alt, #flummox, #marty, #fluxxor, #fluxible 等等就知道了。喔!還有 Facebook 官方的 Dispatcher
路人甲:那為什麼不要就用官方的東西就好?
路人乙:因為那對於初學者來說有點複雜,所以你只要選一個外面既有的實作來用就好
路人甲:好啊!因為我真的不希望把程式碼搞得太難懂
路人乙:就像我剛剛說過的,去找那些實作來學就好
路人甲:那 Angular 勒
路人乙:噁
路人甲:噁?
路人乙:噁
路人甲:其實我就是不想把樣板弄的很髒很複雜而已
路人乙:它真的很簡單,而且網路上有一些 starter kit 或者你可以使用 Yeoman 的產生器
路人甲:需要用一個產生器?Yeoman?那又是啥?
路人乙:產生器就是用來幫你產生一些常用或範例程式碼的東西,而且你可以用它們打造很多的程式。使用 webpack 新支援的 DLL 可以單獨編譯每一隻 app 或者整合在一起
路人甲:ㄜ...我就只有一個 app 或者說就只有一頁, 一個元件而已。隨便啦反正就是一個小東西而已
路人乙:不可能啦,考慮到可組成的元件。意思是說你應該要依據每一個小區塊的功能拆分元件盡可能讓他們一個元件只做一件事。
路人甲:太多東西了啦,是不是捨本逐末了。
路人乙:這是唯一的方式,如果你想要保持程式碼整潔,並且讓其他開發者可以輕鬆看懂你的程式碼,喔還有效能。然後你可以用 hot reload...
路人甲:hot reload?跟 livereload 一樣的東西嗎?
路人乙:不對!這是 webpack 支援的一項厲害的功能,叫做 hot module replacement 其意義就是執行時期注入更換那些要更新的 module 如果你是用 React 的話你可以在網路上找到 react-hot-loader 來幫助你開發元件,重點是可以針對每一個元件單獨處理修改,然後即時更新不需要整個頁面全部重新載入。再加上如果你是用 Flux 架構的話你甚至可以就單獨重複測試 actions。
路人甲:所以我現在已經下載這一堆工具和函式庫,還有其他東西嗎?
路人乙:我剛剛提到啊 Flux 它讓你的程式有良好的架構。如果搭配 Observables 和 Immutable 類型的資料會讓程式更好
路人甲:Observables?我還需要 Immutable 資料?
路人乙:你需要 Observables 來讓處理事件更簡潔而且確保其他非同步函式的執行結果正確,Observables 的意思就是實作觀察者模式,舉實際的例子來說就是透過實作可觀察類別的物件接著訂閱的物件或其他東西就可以在該資料發生改變時同時反應出對應的行為。加上使用 Immutable 最後我們的程式就會具備資料一致,有效率,單純。然後 Observables 將來可以回傳 ES2016 async-generator 函式,
路人甲:什麼?async generator?那是啥
路人乙:一個 generator 函式加強了原本的函式讓其可以回傳多個值,然後非同步修飾子可以讓函式在未來 push 他們的值,所以一個 async generator 函式就是這兩者的結合。透過 yeild 在未來帶入多個值,不過作者 Jafar Husain 已經撤回他的建議,且正在和 Kevin Smith 研究 ES7/ES2016 es-observable 建議
路人甲:靠!我只是想要做個簡單的 app 就得研究這麼多東西,還得關注那些規格?
路人乙:嗯...好吧你可以用 RxJS 它很接近 observable 的規格。已經被很產品廣泛地使用
路人甲:RxJS?為什麼要用它?
路人乙:它可以和已經存在的 Promises, 事件機制搭配,而且運作良好。你可以從任何非同步的程式中建立一個 Observable ,其會被優先處理,繞過處理非同步的值,錯誤等等。如果你要找 Reactive 相關的東西可以去查 cycle framework
路人甲:什麼鬼啊!我只是要建立一個簡單的 app 然後 demo 。確定我能夠做到這些嗎?
路人乙:當然可以啊,不過部署又是另外一個有趣的主題了
路人甲:好!我想我大概明白了一些東西,謝謝你的解釋
路人乙:很好
路人甲:可以讓我整理一遍剛剛你說的那些
路人乙:可以啊
路人甲:所以總結來說我需要將我們程式碼拆成 actions, stores 和元件並且遵循單一方向資料流的原則,用 ES6+ 的語法來撰寫可以讓我用最新版的 JS 的功能這樣一來程式碼會更加簡潔,使用 babel 來轉換編譯 ES6+ 成 ES5 這樣這些 ES6+ 的程式碼就可以在所有瀏覽器上執行,使用 webpack 來處理這些分散的模組檔案,使用 ImmutableJS 來處理所有的資料以及用 RxJS 來處理所有的事件機制以及非同步的函式。
路人乙:是不是很讚
路人甲:我想我忘記了關於那些靜態的資料以及優化就是壓縮檔案的部分
路人乙:小事!webpack 可以讓你匯入這些東西,你所需要做的就是安裝並設定一些 loaders, plugins 而且基本上你已經完成了。你可以匯入元件的 CSS 和圖片然後使用他們,喔對了還有一些取代 CSS 的東西讓你可以在 JS 裡面寫 CSS。
路人甲:...我要回去用 jQuery 了

前言

BEM 不是什麼新東西了,會有這一篇純粹是因為之前都只是依樣畫葫蘆的去使用 BEM 看了幾篇 slider 就上沒有認真理解過。當然寫起來就滿頭包。
這一篇花了一點時間歸納總結這個看似簡單卻實用的 css 組織的方法

介紹

在那些比較小型的網站中,如何組織樣式 css 並不是很大的問題,就是進到該專案的目錄直接撰寫一些 css 或者是 sass。接著透過 sass 的 production 設定編譯它變成一個單一的 css 檔案然後從模組中整理取得 css 變成一個整潔的 package

然而當這個專案變得越來越大越來越複雜,如何組織這些程式碼就變成效能的關鍵。重點不只在花多少時間,還包括寫了多少程式碼,以及瀏覽器要載入多少檔案。
當你處於團隊工作的狀況或需要高效能時這些尤其重要

對於那些長時間的專案或者前人留下來的程式碼當然也是很重要

方法

其實有很多方法目的都在處理簡化 css 程式碼和組織這些 css 讓協同開發者可以維護。很顯然的在像是 Twitter Facebook 和 Github 這種專案會需要不過其他專案通常隨著需求增加其實很快的也會變成大量的 css

  • OOCSS Object Oriented CSS - 透過 css 物件來分離容器和內容
  • SMACSS Scalable and Modular Architecture for CSS - 透過樣式指南或說規則來寫 css ,通常真對 css 樣式定義成 5 種分類
  • SUITCSS - 結構化 class 名稱和有意義的 - 連字號
  • ACSS Atomic CSS - 打破樣式組織的方式變成比較瑣碎的規則,像原子的概念一樣用多個 class 組出樣式,這些 class 通常具有不可分割性。

為什麼 BEM 超過其他

不管你在專案中選擇哪中方式您都能得到結構化 css 和 ui 的好處。其中一些東西並沒有很嚴格的限制並且具有彈性。那些方法通常非常容易理解而且適用於團隊工作。
與其我們自己提出 BEM 的優點,我們決定讓你看看其他人的看法

  • 我選擇 BEM 的理由可以歸納成一點,比起其他方法它比較不會令人產生疑惑,還為我們想用的架構即 OOCSS 提供一個容易識別的術語
  • 我很高興開始使用它,最終我得到了一個有秩序的東西。我得到了一個整潔的系統來命名元素。它釋放了在我腦袋中佔據的大量資源,因為即使像是替元素 class 命名這種微不足道的小事卻意外地佔據大量的大腦資源。
  • 不像 OOCSS 它並不是為了處理關於 css 全部的模組化,反倒是像是命名空間的概念,透過 class 名稱建立各自獨立的 css 模組,並且不會互相干擾
  • BEM 是一個非常有幫助,強大且簡單的命名慣例,讓我們前端的程式碼更好閱讀,好理解,容易維護,容易擴展,更強健也更明確

BEM Blocks, Elements and Modifiers

你不會感到太意外,BEM 就是這些關鍵處理原則的縮寫 - Block, Element, 和 Modifier。一套嚴格的命名規則可以在另一篇命名的文章找到

舉下面 Github 網站為例來說明

  • Block - 一個獨立的區塊具備自己特有的意義,例如: header, container, menu, checkbox, input
  • Element - Block 的一部分並且不具有獨立自己特有的意義,這些元素依賴 Block 的意義。例如: menu item, list item, checkbox caption, header title
  • Modifier - Block 或 Element 上的特殊標記,用來改變原來行為或外觀例如: disabled, highlighted, checked, fixed, size big, color yellow

運作的機制與原理

讓我們來看看在頁面上的一個特定元素怎麼透過 BEM 來實作。我們將從 Github 的樣式指南中取出 button

通常我們需要一個普通的按鈕針對大多數的出現在界面上的狀況然後其他兩種不同的狀態。因為 BEM 透過 class 選擇器來套用樣式,所以我們可以將樣式套用在任何元素上(例如按鈕的樣式套用到 button, a 甚至 div)。這個時候重點就是採用下面這種規格來命名 block--modifier--value

<button class="button">
  Normal button
</button>

<button class="button button--state-success">Success button</button>

<button class="button button--state-danger">Danger button</button>
.button {
  display: inline-block;
  border-radius: 3px;
  padding: 7px 12px;
  border: 1px solid #D5D5D5;
  background-image: linear-gradient(#EEE, #DDD);
  font: 700 13px/18px Helvetica, arial;
}

.button--state-success {
  color: #FFF;
  background: #569E3D linear-gradient(#79D858, #569E3D) repeat-x;
  border-color: #4A993E;
}

.button--state-danger {
  color: #900;
}

延伸閱讀

想知道更多範例可以閱讀Building My Health Skills - Part 3

優點

  • 模組化 - Block 的樣式不應該相依於頁面中其他任何元素,亦為一個 Block 不管放在哪裡都要長得一致,因此永遠不會因為濫用 css 繼承規則的部分就是因為不斷堆疊樣式產生肥大的樣式而降低效能。同時這樣做也具備了讓我們可以輕鬆把一個已經做好的樣式轉換到另外一個專案
  • 重複使用性 - 可以用不同的方式來組合各自獨立的 Block 達到重複使用性也減少 code 的數量,也比較好維護。如果你已經有設計指南或公司內部的一些規則那麼它就能協助你有效率的區分,建立,定義 Block
  • 結構化 - BEM 讓我們的 css 具有容易理解的特性與結構,簡單易維護,不用再一直記著樣式之間的耦合關係。

命名

Phil Karlton 說過在電腦科學的領域只有兩個困難的問題: cache invalidation 和命名

cache invalidation 快取無效化背後的意義就是什麼時候該把暫存刪掉

這是已知的事實,而正確的樣式指南可以明顯的增加開發的速度,debug 和在既有的程式中實作新功能的速度。很不幸的大部份的 css 沒有任何架構和命名規則。
這導致 css 長久以來都是處於異常難以維護的狀態。

這個 BEM 的方法確保每一個開發者使用相同的慣例,正確的命名讓我們未來在修改維護時相對輕鬆

  • block 封裝一個獨立的區塊,上面說過這個區塊需要具備自己的意義。而 block 可以被嵌入其他 block 之中並與其互動,同時維持一致的語意。注意這裡並沒有優先順序或者繼承的概念。也不要使用 DOM 來表示整個區塊

    • 命名:block 的名字可以由英文,數字和連字號組成,目的是將 css class 的命名格式化,當然也可以加入一些簡短的前綴來達到命名空間的效果例如 .block
    • HTML:不要讓侷限 block 套用的元素標籤,任何 DOM 元素只要使用該 class 就能套用 block 的樣式
    • css:
    • 只使用 class name 選擇器
    • 不能直接用 tag 或 id 來選元素
    • 在頁面上不能相依其他 blocks 或 元素
  • element 為 block 的一部分並且相依於 block 的意義,舉例來說就像是 list 中的 item 即為一個 element

    • 命名:element 的命名可由英文,數字,連字號(破折號)或底線組成,css class 的格式為 block 的名字加上兩個底線 __ 再加上 element 名稱 舉例來說 .block__elem
    • HTML:任何在 block 內的 DOM node 就可以是一個 element,在給定的 block 內所有的 element 都具有相等的語意
    • css:
    • 同樣只用 class name 選擇器
    • 不用 tag 或 id
    • 在頁面上不能相依於其他 block 或元素
    • .block_elem { color: #042 }; /* 正確 */
    • .block .block__elem { color: #042; } /* 錯誤 */
  • modifier 是 block 或 element 上的特殊註記,用來改變外觀,行為,或狀態舉例來說就是一個 button 可以有正常可點擊的狀態和 disabled 停止使用的狀態,要注意的是這是附加的樣式不能單獨存在一定要搭配原來的 block/element 才行

    • 命名:同樣的 modifier 名稱可以由英文,數字,連字號(破折號)和底線所組成,css class 格式為 block 或 element 名稱加上兩個 dash .block--mod, .block__mod--mod .block--color-black
    • HTML:modifier 是額外的類別名稱讓我們可以加在 block 或 element 來使用,一個 modifier 只能被用在 block 或 element 並且要保留原來的 class,不能用 modifier 取代掉原來的 block / element
    • <div class="block block--mod">正確</div>
    • <div class="block block--size-big block--shadow-yes">正確</div>
    • <div class="block--mod">錯誤,取代了原來的類別</div>
    • css:直接使用 class name 選擇器
    • .block--hidden { display: none; }
    • 基於 block 層來調整底下的元素 .block--mod .block__elem
    • element 的 modifier .block__elem--mod

備註 sass 用法

.block {
    &__element {
    }
    &--modifier {
    }
}

總結

簡單說來所謂的 B.E.M 就是用 block, element, modifier 三種分類針對 UI 去分析區分歸納,最後協助我們替 class name 命名的一組嚴格規則
block 為一個具有獨立意義的區塊,獨立的意思就是在每個頁面甚至在別的 block 裡面行為外觀都要一致。
element 則是相依於 block 就是 block 的子項目,沒有自己獨立的意義,其意義與使用時要依附在 block 底下
modifier 是一種特殊的註記用來調整改變 block/element 的外觀(樣板)行為狀態
明白這三種定義讓我們可以去歸納介面上的 css 該怎麼組織分類進而管理

因為一個 block 不能相依於其他標籤,獨立不相依真正的意義就是在任何頁面甚至是嵌入其他 block 外觀行為都要一致,不綁定不限制只能在特定元素使用。舉例來說就像 bootstrap 的 .btn 可以在 a, button 甚至 div 都能套用。當 block 之間不會互相污染或被連動影響那麼我們在撰寫修改上就不怕有太多不預期的行為

最後就是該怎麼用:block/element/modifier 都只用 class name 選擇器,不依賴 css 的繼承或是像 .nav > h1 這樣的方式。好處是可以大幅減少過度被覆寫或不斷被繼承而沒有實際效果的 css,只有單一階 css selector 就能選到元素效能也相對提升
第一個是 block 就用一般英文數字命名,實際上三者都是英數加上 _ - 來完成命名
如果是 element 則用 __ 加在 block 和 element 中間例如:block__elem
最後一個 modifier 則是 -- 例如:block--modblock__elem--mod
如果名字較複雜有兩個英文單字則中間用一個 - 連結 如 block__nav-item--disabled

撰寫的流程為先分析 ui 再照規則寫 css 就好了,再透過下面範例快速掌握

<form class="form form--theme-xmas form--simple">
  <input class="form__input" type="text" />
  <input
    class="form__submit form__submit--disabled"
    type="submit" />
</form>
.form { /* ... */ }
.form--theme-xmas { /* ... */ }
.form--simple { /* ... */ }
.form__input { /* ... */ }
.form__submit { /* ... */ }
.form__submit--disabled { /* ... */ }

參考資源

BEM 101
getbem

為了理解 ES6 到底對於 Unicode 萬國碼有哪些新的支援。我們得從原因理解起。

Javascript 有 Unicode 相關的問題

關於 Javascript 處理 Unicode 的方式...至少可以說是很奇怪。這篇文章闡述在 Javascript 中存取 Unicode 的痛點以及 ES6 如何改善這個問題。

Unicode 基礎

在我們深入探討 Javascript 之前,讓我們先確認當我們談到 Unicode 的時候說的是相同的事情。

有關 Unicode 的觀念其實非常簡單,把它想成一個資料庫,存取著您能想到的所有文字符號,且每一個文字符號都對應著一組數字。這個數字就叫編碼位置(Code point),也有人稱碼點 代碼點。這個編碼位置是唯一的。透過這種方式可以簡單的存取特定文字符號而不用直接輸入符號本身。

例如:

  • A = U+0041
  • a = U+0061
  • © = U+00A9
  • = U+2603
  • ? = U+1F4A9

編碼位置通常使用 16 進制的格式,位元左邊捕 0 到至少 4 位,使用 U+ 當作前綴字。
編碼可能的範圍從 U+0000U+10FFFF 超過 110 萬個符號。為了確保其組織性,Unicode 把這個範圍的編碼區分成 17 個區段,各自由 65536 個編碼組成。
如果你曾經看過 Wiki 百科上的翻譯,他翻成平面,由 17 個平面組成。

第一個平面稱作基本多文種平面 Basic Multilingual Plane, 簡稱BMP。這大概是最重要的一個。它包含了大部份常用的字符。一般使用英文的情況下您不會需要 BMP 以外的編碼來編輯文件。

BMP 以外剩下大概 1 百萬個符號屬於補充平面(Supplementary planes or Astral planes)
補充平面的字非常好辨別: 如果某個字符需要超過 4 位元的 16 進制來表示那它就屬於補充平面。

現在我們有了對 Unicode 的基本認識了。來看看如何應用到 Javascript 的字串。

跳脫序列(Escape sequence)

console.log('\x41\x42\x43');
// 'ABC'


console.log('\x61\x62\x63');
// 'abc'

這個東西術語叫做 16 進制的跳脫序列(字元)。由 16 進制格式的 2 個位元組成代表一個編碼位置。舉例來說 \x41 代表 U+0041
跳脫序列可以被用來表示編碼位置從 U+0000U+00FF

另外一種常見的跳脫序列的表示類型如下

console.log('\u0041\u0042\u0043');
// 'ABC'


console.log('I \u2661 JavaScript');
// 'I ♡ JavaScript'

這種格式被稱作萬國碼跳脫序列,算了!還是記英文吧!Unicode escape squences 由16 進制格式 4 個位元組成精準的表達編碼位置,舉例來說: \u2661 表示 U+2661 這種跳脫序列可以用來表示 U+0000U+FFFF 範圍的萬國碼 Unicode 等於是整個基本多文種平面(BMP)

那麼..其他平面呢? 我們需要大於 4 位元來表示其他編碼位置啊! 我們要如何使用跳脫序列呈現它們?

ES6 引進了新類型的跳脫序列: Unicode code point escapes 讓事情變得比較簡單

舉例來說:

console.log('\u{41}\u{42}\u{43}');
// 'ABC'


console.log('\u{1F4A9}');
// '?' U+1F4A9

在大括號之間您可以使用 6 位元的 16 進制,這麼一來就足夠表示所有的 Unicode 編碼。
所以透過這種類型的跳脫序列您可以輕易的跳脫任何您想用的符號

為了兼容 ES5 和舊有的環境,一個不是很好的解決方案是使用成對編碼來代理

console.log('\uD83D\uDCA9');
// '?' U+1F4A9

在這種情況下每一個跳脫字元(跳脫序列)代表一半的編碼位置,2 個代理編碼組成一個字符的 Code point。

注意到這個編碼沒辦法很直覺的看出其規則,這是有一套公式的

例如一個 C 字符大於 0xFFFF 就得對應到 <H, L> 成對的代理編碼

H = Math.floor((C - 0x10000) / 0x400) + 0xD800
L = (C - 0x10000) % 0x400 + 0xDC00

之後我們提到代理編碼指的就是兩個編碼其中之一 第一個的是 H, 第二個是 L

要反轉回來則是

C = (H - 0xD800) * 0x400 + L - 0xDC00 + 0x10000

透過這種代理編碼的機制所有補充平面的編碼位置(U+010000 - U+10FFFF) 都可以使用。不過使用單一跳脫字元來表示 BMP 裡面的字,兩個跳脫字元(代理編碼)來處理剩下補充平面的字很容易讓人搞混,造成很多惱人的後果。

計算 JavaScript 字串的文字(符號)

假設您想計算一個字串的文字有幾個,您會怎麼處理呢?

直覺的想法大概是使用 length

console.log('A'.length);
// 1


console.log('A' == '\u0041');
// true

上面這個例子 length 剛好是字元的數量,說有 1 個文字這很合理。
很顯然的我們每一個文字只需要一個跳脫字元,但實際上卻不是這樣。例如:

console.log('?'.length); // U+1D400 注意這不只是全形A

// 2 


console.log('?' == '\uD835\uDC00');
// true


console.log('?'.length) // U+1D401 

// 2


console.log('?' == '\uD835\uDC01');
// true


console.log('?'.length);
// 2


console.log('?' == '\uD83D\uDCA9');
// true

在內部 JavaScript 把補充平面的字符視為兩個跳脫字元(代理編碼)表示一個字。如果您在 ES5 兼容的瀏覽器輸出您會看到他把他視為兩個跳脫字元 length 為 2 ,人們對於字面上只顯示一個字但是 length 卻為 2 會產生困惑。

計算補充平面裡的文字

回到剛剛的問題,那我們如何計算 JS 字串中有幾個字?
這個小技巧針對代理編碼做處理,當我們認出這兩個跳脫字元會組成一個字的時候只計算一次

var regexAstralSymbols = /[\uD800-\uD8FF][\uDC00-\uDCFF]/g;

function countSymbols(string) {
  return string.replace(regexAstralSymbols, '_').length;
}

或者您也可以使用 Punycode.jspunycode.ucs2.decode 方法可以取得一個字串並回傳一個包含 Unicode 編碼位置的陣列。如此一來您就可以計算幾個字了。

在 ES6 您可以透過 Array.form 做類似的事情,透過使用字串的 iterator 來切割字串成為一個陣列

var astral = Array.from("???");
console.log(astral);
console.log(astral.length);
// 3

或者使用 ...

console.log([..."???"].length)
// 3

使用上面提到的這些方法,我們可以解決計算幾個字的問題。

看起來一樣,但卻不一樣

但是如果我們開始去賣弄我們從文章中學到的知識,計算文字的數量甚至更多複雜的操作例如下面這段程式碼

console.log('mañana' == 'mañana');
// false

JavaScript 會告訴我們這兩個字串不一樣,但看起來明明就一樣。
試著到這個網址看看

Javascript escapes 工具告訴我們其中的不同

console.log('ma\xF1ana' == 'man\u0303ana');
// false


console.log('ma\xF1ana'.length);
// 6


console.log('man\u0303ana'.length);
// 7

第一個字串包含的是 U+00F1 是一個拉丁字小寫 N 加上波浪號。而第二個字串裡面的事 U+006E 拉丁字小寫 N 加上 U+0303 波浪號,兩個編碼合體成一個字。這樣你明白了為什麼他們不一樣了吧。

然而如果我們希望兩個字串計算結果都會是 6 個字呢?
在 ES6 也相當直覺

var normalized = "mañana".normalize('NFC'); // 把字串標準化


console.log(Array.from(normalized).length);
// 6

console.log([...normalized].length);
// 6

這個標準化 normalize 方法是內建 String.prototype 的方法,他會根據Unicode normalization的規則執行,找出那些字的差異,如果找到那種由兩個代理編碼組成的字卻長得跟另一單一編碼位置一樣的字,它會把它轉成單一的那種編碼。

[...'mañana'].lenght // U+00F1

// 6

[...'mañana'].length // U+006E + U+0303

// 6


// 透過程式碼驗證

var normalized = "mañana".normalize('NFC');
console.log(normalized[2] + " = " + normalized.charCodeAt(2))
// ñ = 241, 241 轉成 16 進制 F1

為了向下相容 ES5 和舊環境可以使用這個Polyfill

事情還很複雜 - 計算其他組合式的代理編碼

光上面這些還不夠完美,編碼位置可以有多種組合方式其結果看起來是一個字,但是卻沒有標準化的格式(或者說沒有相同樣子的字取代)。
這種時後 normalization 就幫不上忙了。

大部份開發者應該很少遇到這類問題吧???

var q = 'q\u0307\u0323'.normalize('NFC') // q̣̇

// 經過 normalize 還是 q\u0307\u0323


console.log([...q].length);
// 是 3 不是 1


console.log([...'Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘!͖̬̰̙̗̿̋ͥͥ̂ͣ̐́́͜͞'].length);
// 是 74 不是 6

此時您可以使用正規式來移除那些組合的符號

var sample = "Z͑ͫ̓ͪ̂ͫ̽͏̴̙̤̞͉͚̯̞̠͍A̴̵̜̰͔ͫ͗͢L̠ͨͧͩ͘G̴̻͈͍͔̹̑͗̎̅͛́Ǫ̵̹̻̝̳͂̌̌͘!͖̬̰̙̗̿̋ͥͥ̂ͣ̐́́͜͞";
var pattern = /([\0-\u02FF\u0370-\u1DBF\u1E00-\u20CF\u2100-\uD7FF\uDC00-\uFE1F\uFE30-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF])([\u0300-\u036F\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]+)/g;
var stripped = sample.replace(pattern, function(ele, symbol, marks) {
  return symbol;
});
console.log(stripped.length);
// 6

這種做法可以移除其他用來組合的符號,只留下那些我們要的字。這種解決方案甚至連 ES3 都能使用。

計算其他類型的字形集合(語素簇) Grapheme Cluster

對於像 நி(U+0BA8 + U+0BBF = \u0ba8\u0bbf) 這種字集或者像韓文是由一堆母音和子音組成的例如 ( + + ) 上面的邏輯仍然是相對粗糙。

Unicode 標準附件 #29 提供一個演算法用來判斷字形的邊界,就是怎麼樣算是人看的一個字。
如果是為了給所有 Unicode 絕對精準的解決方案那麼時做這個邏輯可能是比較正解的方法。

反轉字串

你可能覺得在 JavaScript 中反轉一個字串很簡單。對嗎?
通常你會這麼做

function reverse(string) {
  return string.split('').reverse().join('');
}

看起似乎可行

reverse('abc');
// 'cba'


reverse('mañana'); // U+00F1

// 'anañam'

然而當字串混雜著一些代理編碼即兩個編碼位置組合成的一個字或者補充字面裡的字時全部就都亂了套。

reverse('mañana'); // U+006E + U+0303

// 'anãnam' 

// 看吧! `~` 跑到 `a` 上面了


reverse('?') // U+1F4A9

// '��' => `'\uDCA9\uD83D'`

// `?` 大便符號完全錯誤因為兩個編碼位置反了

在 ES6 裡面修正了這個問題

console.log(Array.from("???").reverse().join(''));
// "???"

console.log([..."???"].reverse().join(''));
// "???"

但仍然沒有解決涉及多個編碼組合成看起來像一個字的問題。

幸運的是有人解決了反轉字串遇到怪字這個問題,只要透過Esrever

// 使用 Esrever (https://mths.be/esrever)


esrever.reverse('mañana') // U+006E + U+0303

// 'anañam'


esrever.reverse('?') // U+1F4A9

// '?' U+1F4A9

在字串方法中使用 Unicode 的問題

除了陣列 reverse() 的行為外,這種問題也影響到字串的方法。

轉換編碼位置成文字(符號)

String.fromCharCode 讓我們可以用 Unicode 編碼位置來建立一個字串,不過呢只有在基本多文種平面(BMP)範圍內是正常的(U+0000 到 U+FFFF),如果我們用了在補充平面的字將會得到非預期的結果:

String.fromCharCode(0x0041); // U+0041

// 'A' U+0041


String.fromCharCode(0x1F4A9) // U+1F4A9

// '' 是 U+F4A9, 而不是 U+1F4A9

而解決方法就是用上面提到的公式自己計算,並且把拆開的兩個編碼當作參數帶入

String.fromCharCode(0xD83D, 0xDCA9);
// '?'  U+1F4A9

16 進制在 C語言、Shell、Python、Java語言及其他相近的語言使用字首「0x」,例如「0x5A3」。

而在HTML,十六進制可以用「x」,例如 &#x0041; 會等於 「A」。

如果你不想要自己處理這些麻煩的計算您可以使用 Punycode.js 提供的工具

punycode.ucs2.encode([0x1F4A9]);
// '?' U+1F4A9

除了上面這些方法,幸運的是 ES6 也引入了新的方法 String.fromCodePoint() 就可以直接用來轉換補充平面裡面的字了

String.fromCodePoint(0x1F4A9);
// '?' U+1F4A9

同時呢為了向下相容舊環境您可以使用 Polyfill。

從字串中取出一個字

如果您想用 String.prototype.charAt(index) 來擷取第一個字符,遇上大便符號?這種代理編碼類型的字,這個方法只能夠抓出第一個編碼。

'?'.charAt(0) // U+1F4A9

// '\uD83D' 

// U+1F4A9 代理編碼的兩個編碼中第一個是 U+D83D

在 ES7 的建議中已經有提出 String.prototype.at(index) 來處理這個問題了。

'?'.at(0) // U+1F4A9

// '?' U+1F4A9

同樣的在 ES5 和舊環境中還是可以找到 Polyfill 來處理。

從字串取得字的編碼位置

類似於上面的狀況,如果您使用 Strint.prototype.charCodeAt(index) 來檢索字串中第一個字的編碼位置,您一樣會取得代理編碼 2 個編碼中的第一個編碼(就是上面提到的 H)

'?'.charCodeAt(0);
// 0xD83D

// 瀏覽器會給 10 進制 55357 換算之後的確是 D83D

再一次感謝 ES6,一樣提供了 String.prototype.codePointAt(index) 方法來解決這個問題。

'?'.codePointAt(0)
// 0x1F4A9

遍歷字串中的每個字

假設您想要用迴圈遍歷(就是一個字一個字取出)一個字串,分別對每個字做點處理。

在 ES5 裡我們可能要先處理成陣列

function getSymbols(string) {
  var length = string.length;
  var index = -1;
  var output = [];
  var character;
  var charCode;
  while (++index < length) {
    character = string.charAt(index);
    charCode = character.charCodeAt(0);
    if (charCode >= 0xD800 && charCode <= 0xD8FF) {
      // 這邊我們假設不會出現那種只有一半的代理編碼

      output.push(character + string.charAt(++index));
    } else {
      output.push(character);
    }
  }
  return output;
}

var symbols = getSymbols('?');
console.log(symbols);

不意外的 ES6 又出來拯救我們了,在 ES6 裡只要用 for...of 就可以準確的取出每一個

for (let symbol of '?') {
  console.log(symbol == '?');
}
// true

其他問題

Unicode 的確影響了很多 String Method 的行為,包含我們在這裡沒提到的 substring, slice 所以實作時請小心。

Unicode 在正規式中的問題

匹配代碼位置(Code Point)與 Unicode 標量值(Unicode Scalar Values)

. 句點在正規式中只會匹配一個字元即我們上面說的一個編碼(U+0041),本來這都很合理,但因為 JavaScript 採用了代理編碼的機制,用了兩個實際的編碼組合成一個字。一旦我們要匹配補充平面的字時,永遠不會匹配成功。

/foo.bar/.test('foo?bar')
// false

讓我們再想想...還有什麼正規式的寫法可以匹配 Unicode 字符

console.log(/^[\s\S]$/.test('?'));
// false

還是 GG ,事實證明正規式要匹配一個 Unicode 編碼位置並不是那麼直覺。

console.log(/[\0-\uD7FF\uE000-\uFFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:[^\uD800-\uDBFF]|^)[\uDC00-\uDFFF]/.test('?'));
// true

當然啦!在實際開發時我是絕對不想寫這些正規式,光寫就暈了更何況還要 debug。為了取得上面那堆正規式我們可以偷吃步使用 regenerate 函式庫。它可以很輕鬆地幫我們產出這些正規式

regenerate().addRange(0x0, 0x10FFFF).toString()

這個正規式可以匹配基本多文種平面,補充平面的字,甚至只有一個代理編碼。

只寫一個代理編碼在技術上是可行的,但因為他們不會對應到任何實際的字,所以應該避免。
Unicode 標量值(Unicode Scalar Values)指的是所有的編碼位置但扣掉那些代理編碼的編碼位置
下面是如何產出符合 Unicode Scalar Values 規則的正規式

regenerate()
 .addRange(0x0, 0x10FFFF) // 所有 Unicode 編碼位置

 .removeRange(0xD800, 0xDBFF) // 減去第一位的代理編碼

 .removeRange(0xDC00, 0xDFFF) // 減去第二位的代理編碼

 .toRegExp()

regenerate 可以協助我們建立那些複雜的正規式,用相對語意化的方式讓我們比較好維護。

ES6 替正規式加入了 u 修飾符(flag) 就是在正規式尾巴那些用來設定比對方式的參數
例如 /[\w]/g

  • g 全域比對
  • i 忽略大小寫
  • gi 全域比對 + 忽略大小寫

現在多了 u 讓正規式用 . 在匹配時可以正確的匹配到補充平面裡的字

/foo.bar/.test('foo?bar');
// false


/foo.bar/u.test('foo?bar');
// true

注意: . 仍然不會匹配到換行字元,當設定了 u flag 之後在兼容環境中等於是使用下面的程式碼

regenerate()
  .addRange(0x0, 0x10FFFF)
  .remove(
    0x000A, // <LF> 

    0x000D, // <CR>

    0x2028, // <LS>

    0x2029, // <PS>

  ).toString();

補充平面的範圍設定

先想想這種狀況 /[a-c]/ 這樣寫可以比對 U+0061U+0063 即 a 到 c 。那如果換成 /[?-?]/ 勒?
理論上應該是要匹配 U+1F4A9U+1F4AB

但實際上卻是...

console.log(/[?-?]/);
// SyntaxError: invalid range in character class

原因是實際上這個正規式長成這樣

var pattern = /[\uD83D\uDCA9-\uD83D\uDCAB]/;
// SyntaxError: invalid range in character class

我們原本想要的是從 U+1F4A9U+1F4AB 但現在正規式卻變成先來一個 \uD830 然後從 \uDCA9-\uD83D 範圍就錯了。

ES6 的 u flag 和新版 Unicode 表示法又一次解決了我們的困難

console.log(/[\uD83D\uDCA9-\uD83D\uDCAB]/u.test('\uD83D\uDCA9'));
// true

// 匹配 U+1F4A9


console.log(/[\u{1F4A9}-\u{1F4AB}]/u.test('\u{1F4A9}'));
// true

// 匹配 U+1F4A9


console.log(/[?-?]/u.test('?'));
// true

// 匹配 U+1F4A9


console.log(/[\uD83D\uDCA9-\uD83D\uDCAB]/u.test('\uD83D\uDCAA'));
// true

// 匹配 U+1F4AA


console.log(/[\u{1F4A9}-\u{1F4AB}]/u.test('\u{1F4AA}'));
// true

// 匹配 U+1F4AA


console.log(/[?-?]/u.test('?'));
// true

// 匹配 U+1F4AA


console.log(/[\uD83D\uDCA9-\uD83D\uDCAB]/u.test('\uD83D\uDCAB'));
// true

// 匹配 U+1F4AB


console.log(/[\u{1F4A9}-\u{1F4AB}]/u.test('\u{1F4AB}'));
// true

// 匹配 U+1F4AB


console.log(/[?-?]/u.test('?'));
// true

// 匹配 U+1F4AB

可惜的是這個方法不能兼容 ES5 和舊環境,如果您真的必須要兼容舊環境那麼您可能只能使用 regenerate, 或其他類似的函式庫 來產出那些正規式進行匹配了。

真實世界中的其他 Bug 及該如何避免

看到了吧!Unicode 在 JavaScript 中奇怪的行為造成許多問題。許多開發者在處理字串時並沒有考慮到補充字面的問題(舉手; 我就是其一),甚至包含知名的函式庫 Underscore.stringreverse 也沒有處理關於補充字面產生的問題。

畢竟處理這些問題的確很麻煩,而且好像沒必要???

在測試中參一坨屎吧 XD

無論您正在寫什麼樣功能的 JavaScript 試著在測試行為的字串中加入 ? 然後看看會不會炸掉。
這有助於您發現 Unicode 的問題。

結論

一般處理單一語系的開發者其實不太容易注意到這些問題。這也是在學習 Babel 的過程中為了理解為什麼要特別強調 Unicode 而做些研究寫的學習筆記。