身為一個 junior 前端工程師,為了求職或者為了滿足自己的成就感,或多或少都會想自己寫一個 「side project」 (以下皆以玩具代稱) ,用說的很容易,實際上想要動手卻不知道從何開始,因此想要稍微紀錄並分享一下我是如何一步步打造屬於自己的玩具。
常常看到很多大神們在社群上公開自己的作品,進而激發自身潛在慾望。
「啊,這個東西好酷啊,不曉得怎麼做的,好想也寫一個玩具」諸如此類的想法。
但往往真正要動手的時候腦袋一片空,不曉得怎麼挑選一個主題來做。
關於玩具「主題」的選定,來源或許有以下:
不管挑哪個做都好,最重要的還是得讓自己有興趣,不然就沒戲唱了。
想著想著,最後挑了第四個「解決自己、或他人遭遇的痛點」
我不常逛 Dcard ,頻率大概一星期一、二次吧。
但是我會去某些版專門看藍頭 (男生) 或粉頭 (女生) 發的文,然而 Dcard 並沒有針對性別過濾這樣的功能。
雖然看起來理由有點薄弱,不過只要能說服自己就是好理由!
很多時候驅使我們做任何事,理由往往不需要很冠冕堂皇。
所以如果跟我一樣不太知道要做些什麼,也是蠻推薦從解決日常生活中痛點起手。
也許你可能會說「想做的東西大家都做過了」,但是這個痛點不見得別人有很好解決掉,而這個工具是為了解決這個獨有的痛點產生的,這麼想是不是就有做的價值了 XDD
最後我幫這個小工具取名為 「Dcard helper
」
…我知道很沒創意啦 QQ
不曉得大家有沒有一個狀況,就是當在做任何事情之前一定要做某件事,若跳過感覺就怪怪的,諸事不順這樣。
我自己是有這樣的情況,而且發現透過進行「特殊的」儀式會讓自己更有自信、更好的累積成就感。
好啦,不賣關子,說的好像什麼邪教儀式似的 XD
其實只是透過一些行為培養、累積自己的信心,促使自己持之以恆。
進入職場後,在工作上我們運用了 Gitlab 做了以下這些事情:
而這些事情都是我剛入職前沒有掌握到的,我認為做了這些事情能讓整體 Code 的品質上升。
這些儀式雖然一開始做起來很麻煩,但累積起來會讓我莫名有成就感,而且也方便他人日後觀看我是如何完成這個玩具的。
另外 README 、 CHANGELOG 的目的是為了加深當初為什麼自己要做這玩具的想法以及紀錄玩具在每個 Issue 間的變化
。
當然每個人的想法不一樣,這是我自己測出來如何讓自己堅持下去的方式,分享給各位參考囉。
有了想法之後,接下來就可以開始進行技術層面的粗估,像是要用什麼前端要用什麼框架、後端要怎麼做、接哪個資料庫等等…。
到這邊可能就會開始有人開始不知所措,不知道要怎麼選。
就以我的例子來舉例,「我想要一個能針對 Dcard 發文者性別篩選的網站」,用最直白的話來描述我想做的玩具大概是這樣。
也就是說,為了達成這個目標可能我需要:
就是說可以逐層抽絲剝繭的推敲出這個玩具大概的輪廓,當然初期可能推估出來的東西是錯誤的。
但至少有整理出東西,這樣也方便跟較有經驗的開發者討論,對方也比較好針對問題點回覆,簡單來說就是「要先有自己的想法,別人才有辦法跟你討論」
想法有了、也釐清可能會使用那些技術實作了,接下來就是動手的時刻了!
啊然後勒…?
這時候不妨再回想一下自己當初訂下的需求、目標。
以這個玩具為例:
這樣推敲下來順序就是 「Dcard 爬蟲程式」 > 「Web server」 > 「網站 UI」,
不過我通常會從畫面去開需要那些 API ,所以順序我會改成 「Dcard 爬蟲程式」 > 「Web server」 > 「網站 UI」
所以第一步就是從爬蟲程式起手,我應該先了解 puppeteer 如何使用。
玩具剛開始的時候,會建議先訂一個小小的目標藉以培養自己的信心,若以這個玩具為例:
console.log
印出.json
的形式於是,我們就有了一個真的可以運作的爬蟲程式,隨著每次的運行應該會對這隻程式有一些想法,接下來就是按你所想的去優化它,好比說:
最後,這隻爬蟲程式就完成了,接著就可以往下個階段前進。
前面提到我很享受這些「儀式感」,一方面除了帶給我成就感之外,也能如實的反應我的開發情況:
比方說這個 issue
最重要的是做這些事情會讓我感受到,這個玩具是隨著我每次的 issue 完成,逐步成長的,自然會更有動力想完成它。
最後,稍微閒聊一下在做這個玩具時,遇到那些問題、後來是怎麼避開(無視)的。
這部分我是初次使用 puppeteer 進行爬蟲,也遇到了一些奇怪但不知道怎麼解只好繞開的情形,如果有其他大大對這部分有心得的話還請不吝分享給小弟,感恩。
於是為了練練手也沒什麼想法,就很直接寫了一隻爬 google 搜尋結果的程式 example-crawler。
爬是能爬啦,不過多爬幾次就會被關切是不是機器人,但至少是個有趣的體驗。
在我開始進行 Dcard 爬蟲程式的撰寫前,有稍微的 google 一下有沒有相關知識可以科普,於是發現也有不少前輩做過類似的事情,但好像沒有我想做的事 (太小眾了) ,
但意外地發現 Dcard 可以呼叫 API 就好,不見得真的要爬實際網站內容。
於是我就開了一個 issue 去記錄這件事情,然後開始我的嘗試。
一開始想說既然都有 API 了,是不是不用爬蟲也可以直接呼叫這支 API 之類的,寫好之後發現實際呼叫後的內容跟預期的不太一樣,原來是 Dcard 有使用 Cloudflare 防護。
後來還是乖乖地回來使用 puppeteer 繞開這件事情, puppeteer 背後其實是控制 Chrome 或 Chromium 瀏覽器來做我們想做的事情。
我想請 puppeteer 做的事情,就很像手動把 Dcard 的 API 貼到瀏覽器的網址列一樣,透過這個方式,我迴避掉了第一個難關。
但後來發現這個 Cloudflare 防護其實不只這麼簡單,要是爬蟲發出的請求沒有隨機的等待時間、或是根本沒有等待時間,不用多久就會被 Cloudflare 擋下來要求證明你不是機器人了 XDD
這部分我也想知道有沒有辦法教 puppeteer 去回答那些問題。
程式寫著寫著,隨著 issue 一個個的被我關閉,也慢慢的發現程式碼的重複片段有點高。
可以看到我這個時候的 commit ,像是爬文章 (post)、爬看板 (forum)、爬熱門文章 (popular-post) ,裡面重複做的事情相當多,大致像是:
然而這裡要分享的不是如何重構,而是重構的時間點。
上面打的是我已經完成爬取文章、看板、熱門文章的爬蟲程式後綜觀整體才釐清的東西。
其實在更早在寫這部分程式的時候多少就有一點很「冗餘」的感覺。
當時有試著想要邊寫邊進行重構,但其實有些時候很多東西都還沒寫完,很難綜觀全局去優化。
也因為這樣花了比平常更多的時間去完成,而且重構的部分也不了了之。
後來乾脆等東西都寫完了,再開個重構的 issue 專心處理還比較省事。
所以我是建議如果功力不夠深厚,還是不要貿然嘗試邊寫邊優化、重構,不如等到能看清全貌了再動手會比較省事、省時。
最後這部分是運用了 class 的繼承去處理掉重複性質過高的程式碼。
進行爬蟲的時候,可能會需要處理一些錯誤情況,而這個情況也是我偶然發現的。
我寫的程式在進入錯誤處理的區塊時,會將當前的頁面截圖並保存圖片,方便我排查錯誤。
但我後來發現只要進入到截圖這個部分,程式就會卡住沒有回應,直到我關閉無頭模式 (headless) 後才發現原來是崩潰 (crash) 了!
網上搜了一些資料、甚至開新的專案以最簡短的程式嘗試模擬錯誤情況,就沒有遇到這樣的問題。
仔細觀察自己寫的程式,沒有發現明顯的錯誤,甚至也不知道成因的情況下,我選擇繞路走。
我把進入錯誤處理時的動作改成了截取前的頁面並存成 PDF ,雖說這樣就避開了,但心裡還是有點納悶的。
而且不是完美的解,畢竟存成 PDF 官方有說只有在無頭模式開啟時能用。
希望這部分有研究的大大們能夠指點小弟看看問題出在哪,感恩感恩。
目前我的爬蟲策略是這樣 (第一次執行程式):
_forums
、熱門看板就是 _popular_forums
初期我是先採取這樣的策略,確保資料的完整性。
但很快地就發現一個問題,那就是爬太久了~~
記得那時候是 228 連假時開始爬的, Dcard 那個時候的看板數量有 4XX 個吧 (含隱藏、校版)
每個發出的請求,我都隨機間隔 1 ~ 5 秒,猜猜爬完 Dcard 我需要花多久 ?
為了記錄這些時間,我寫了一隻小程式去紀錄時數、也把爬每個版需要花多少時間記錄下來方便自己分析。
總時數大概是落在 52.19
小時左右,那時候我有手動中斷一次可能圖片的看板總數與總時數不是最精確的,但這個數據還是相當值得參考。
問題來了,現在狀況是爬蟲不會辨識內容,只會從頭爬到尾。
如果說是初次運行,為了資料的完整性,徹底地從頭爬到尾需要這些時間是合理的。
但問題是之後如果要更新資料,即便我另外取得了熱門看板的 TOP 10 看板,按目前這個從頭爬到尾的爬蟲機制來看,想必還是很久的。
更何況是 TOP10 的看板,那個文章總數大概會嚇死人。
於是我的做法是這樣,由於文章的 API 不帶任何 QueryString 的話預設是從新到舊,這部分我沒有排序過,因此資料庫也是按這個順序儲存。
因此資料庫的第一筆會是那個時間點下該看板文章的第一筆。
就是說我只要在開始文章爬蟲之前,查一下資料庫看第一筆的 createdAt
是幾月幾號,比方說是 2021-02-08 15:48:36 這樣好了,
在進行爬蟲的時候,就去檢查這次爬到的資料內每一筆的 createdAt
有沒有在 2021-02-08 15:48:36
之後,
倘若開始有 2021-02-08 15:48:36
之前的資料出現就剔除,並把其餘符合的資料存入,中斷該看板的爬蟲。
但這樣可能還是會有問題,比方說如果有人同時壓秒發文之類的、或者其他沒考慮到的問題。
我想到的配合方案就是定時跑 TOP10 熱門看板的爬蟲,然後再安排時間跑完整看板文章的爬蟲去補正。
所以就是還需要去研究一下排程
的部分,如果想讓這部分的機制更完善的話啦。
但撇開這些來說,我當初想做的事情已經完成了,這邊只是讓它更好而已。
如前面所述,是以 Angular + Angular Material + Bootstrap 構建的,而因為玩具性質的關係,這次前端所佔的技術相對少,較著重在爬蟲部分,所以倒也沒遇到太大問題。
會想要使用 Decorator 是因為在工作上有使用到,覺得這東西其實蠻方便的,而且很適合用來搭配 Viewblocker 。
Viewblocker
就是進行非同步操作時,會有各式各樣的 loading 畫面來遮住部分視野的東西。
我寫了一支 Viewblock Service
去控制 Viewblocker
的開關。
這部分一開始踩了蠻多的雷,因為 Decorator 算是 TypeScript 提供的語法糖, Angular 的生命週期本身是不會控管的。
而問題就出在這個 Decorator 需要取得 Viewblocker Service 的 Instance 才有辦法進行 Viewblocker 的開啟與關閉控制。
因此我需要在 Viewblocker Service 被 new 出來的時候,把 Instance 透過靜態 (static) 方法給出去。
所以一開始我一直撞牆的原因是 Viewblocker Service 太慢被 new 出來,導致 Decorator 拿不到 Instance ,
於是我把 Viewblocker Service 拉到 AppComponent 內進行依賴注入,這樣就能在 AppComponent 被建立時確保後面用到的 Viewblocker Service 也被建立。
上面一個問題解決之後,很快的又遇到一個地雷。
通常 Viewblocker 不能遮住選單列、導覽列 (Nav bar) ,試想如果今天使用者點到某個頁面 loading 超久,但又被蓋住全螢幕,逼得使用者只能重新整理,這樣 UX 體驗肯定很差。
因此需要確保 Viewblocker 不能遮住選單列、導覽列。
而我就是踩到這個地雷, Viewblocker 元件放置的位置會蓋到手機模式的側選單。
最後是使用 Angular Material 提供的 CDK - portal 把這個問題解掉,因為我把 Viewblocker 變成動態的插入到需要的地方,
這樣就能很好的避開不能被 Viewblocker 蓋到的地方。
可以看到我在
AppLayoutComponent
的ngOnInit
中設置 Viewblocker 要蓋到哪裡。
雛型大概是這個樣子 (還沒串 API)
呼…總算是寫完了。
當初在準備要寫的時候,就有預感這一篇肯定會寫很久而且篇幅有點長。
畢竟我不是一直都在寫這個玩具,有時候工作比較忙或者倦怠一上來就不想動了。
不過也幸虧自己的這個開發習慣,不管從什麼時候開始都能夠快速掌握目前開發到哪邊。
其實這一篇應該只適合給新手參考,有些程度的工程師大概會覺得這整篇都是廢話 XDDD
不過還是希望能幫助那些想要嘗試寫自己玩具的新手,那怕是一點點心路歷程也好,最終促使這篇文的產生。
因為工作忙碌、回家就犯懶…這個玩具老實說我也還沒有寫完,這篇文出去之後會慢慢的把它完成,說不定也是變相的鞭策自己 (?
一些踩雷心得也會補在閒聊那個區塊。
Live Demo 的話,可能會再等一陣子,大概是等我滿意了才會想租個空間放上去吧 XDD
最後最後,感謝我的好室友在我鬼打牆的時候給我幫助。
有錯的話鞭小力一點,我怕痛 QQ
同上篇,這篇也是寫玩具 (side project) 時遇到的需求,麵包屑 (Breadcrumbs) 這個功能亦是網站相當常見的元素,那麼又該如何透過 Angular 的 Router 搭配 Bootstrap 4 建立麵包屑呢?
因為這個需求源自於我的玩具 (side project) ,而麵包屑的做法在網路上查完一輪後,因人而異的有各種方式能實作,所以本文的做法僅供參考。
如同上一段提到的,這個專案環境使用懶載入的方式載入模組 (lazy-loading-ngmodules) ,而網路上查到的資料卻很少提到在這個前提下應該怎麼調整,導致照著做是無法順利運行的。
又爬了好一陣的資料,折騰了好久才完成,但完成的版本需要再麵包屑元件的 constructor(){}
內進行 router.events
的訂閱。
但根據查到的資料,constructor(){}
指的是物件實體剛被建立時,此時是不包含在生命週期中的,而 constructor(){}
應該只做依賴注入,不要亂加一些東西。
想想,又對於這個做法不滿意,想辦法改良後最後決定放在這邊記錄下來。
在各個父層路由定義 data
物件內容,如:
app-routing.module
1 | const routes: Routes = [ |
back-routing.module
1 | const routes: Routes = [ |
在 AppComponent
中訂閱 Router
的事件, 而這些事件相當多,在此只需要針對 NavigationEnd
進行處理即可。
另外要記得在 ngOnDestroy()
階段取消訂閱。
接著還需要一隻服務 BreadcrumbService
將資料存下來,供 BreadcrumbComponent
使用
app.component
1 | import { Component, OnInit, OnDestroy } from '@angular/core'; |
當 event
為 NavigationEnd
時,透過 setActivatedRouteRoot()
將資料儲存。
breadcrumb-service
1 | import { Injectable, EventEmitter } from '@angular/core'; |
當 setActivatedRouteRoot()
被觸發時,將資料 emit
出去,這時 BreadcrumbComponent
只需要訂閱 routeEvent
事件就好了~
接著來處理本次的主角:
BreadcrumbService
ngOnInit()
內 訂閱BreadcrumbService
的 routeEvent
事件,確保資料的取得ngOnInit()
階段訂閱,所以程式首次運行必須跑一次 getActivatedRouteRoot()
ngOnDestroy()
階段取消訂閱將取回的資料印出觀察,會發現路由的資料其實是一層層包覆的資料結構
breadcrumb.component.html
1 | <nav aria-label="breadcrumb"> |
breadcrumb.component.ts
1 | import { Component, OnInit, OnDestroy } from '@angular/core'; |
特別要注意如果是使用懶載入模組的方式,則需要判斷
child.component
否則會有麵包屑名稱重複的問題,原因參考資料中有提到,有興趣的不妨看看。
這篇文章參考的內容
成果圖:
本文不會有完整程式碼提供下載,僅紀錄相關程式碼片段,如果之後這個玩具有完成,會再考慮要不要公開。
]]>這篇主要記錄自己在寫玩具 (side project) 時遇到的小問題,也是蠻常見的需求。
我習慣開發時使用 Bootstrap4 ,而在 Bootstrap4 中有提供 NavBar 的元件供開發者快速套版,亦有相關的 ClassName 可供使用,那麼該如何進一步的提升使用者體驗呢 (UX)?
常常看到某些網站的 NavBar 在使用者往下捲動 ScrollBar 時, NavBar 會往上隱藏;反之,當往上捲動時則顯示。
那麼該如何調整 Angular & Bootstrap 來達成我們的需求呢?
需要進行 HTML & CSS 的調整,程式碼如:
navbar.component.html
1 | <header class="sticky-top" [ngClass]="{'hidden': isNavBarHidden}"> |
navbar.component.scss
1 | .navbar-toggler { |
透過調整 .sticky-top
並且當 ClassName 內同時具有 sticky-top
、 hidden
時透過 transform
隱藏 NavBar 。
接著需要針對 scroll 進行監聽
navbar.component.ts
1 | import { Component, OnInit, OnDestroy } from '@angular/core'; |
說明:
scroll 滾動事件
ngOnDestroy
時將監聽卸掉scroll 滾動事件
觸發的非常頻繁,需要加上額外的措施requestAnimationFrame
的補充scrollY
的位置,如 last_known_scroll_position
window.scrollY
與 last_known_scroll_position
是往上捲還是往下捲last_known_scroll_position
的值成果展示
因為還沒這個玩具還沒完成,暫時還不想公開,所以就不附上完整的 code 了。
不過這個範例還蠻簡單的,我想應該貼上片段就足夠了 (?)
這樣就達成強化 NavBar UX 的目的了:
如果不想只單純的使用 sticky-top
不妨嘗試這樣的方式,讓網站更加活潑哦~
自上一篇文章後,就很少上來發表文章了,一方面是工作到家就有點累了,更別說沒有想到什麼適合寫的主題。剛好前幾天有個 Issue 希望我研究如何在 Angular 中快速的切換預設 / 暗色主題,也就是所謂的開關燈模式囉。
稍微了解情況後便立即開始展開網路上的搜尋,初步的整理出了以下兩種方案:
className
的方式切換主題angular.json
使其打包出兩份不同的主題 CSS ,並且將其動態引入先破梗,兩種方案都有優缺點,端看團隊的情況能接受哪一種方案。最後我們團隊是採用第二種方案,因為這對我們來說,能避開最多可預見的問題。
透過異動 className
達成切換主題的方式可以參考這篇文章做一些微調即可。
嘗試過後覺得不太適合我們,理由如下:
className
來切換主題,以我們專案來說會出現 CSS 權重問題造成跑版。Bootstrap 4
& Angular Material
等相關的 SCSS ,評估後覺得這個不是目前最佳解,暫時跳過。做法如下:
default-theme.scss
、 dark-theme.scss
angular.json
styles
區塊配置input
、bundleName
、inject
屬性input
- 要載入的 SCSS 路徑bundleName
- 獨立打包的檔案名稱inject
- 是否自動載入至 index.html
configurations
的 outputHashing
配置bundles
,確保打包後的 CSS 檔案名稱不會被加上 hashbuild
內的 options
區塊配置,加入 extractCss
屬性,設置為 true
CSSLoaderService
用來動態引入指定的 CSSindex.html
,使其預設載入 default-theme.scss
範例:GitHub
成果 Gif 圖
透過方案二的做法,能有效的控制 CSS 大小,不會因為兩種主題而使 CSS 檔案膨脹,而這個做法也相當的簡單暴力。
甚至可以繞過很多不好處理的問題,像是 SCSS 內的變數宣告順序、又或者是多了 !default
的 SCSS 變數,方案二並非方案一是將全部 SCSS 都打包再一起成為 CSS ,所以會讓情況單純很多。
當然方案二也不是完全沒有缺點的,像是:
因緣際會之下,同事 A 冷不防地丟來一則訊息,問我有沒有參加過 MOPCON ,理由是他沒有參加過不確定是否該怒衝一波。於是也真的就這麼巧,六角社團內也剛好有個免費領取公關票寫心得的活動,而寫心得對我來說並不是一件特別困難的事情。確認完同事是否「孤單寂寞覺得冷」真的需要我陪同參加後,就毫無懸念的報名了。
再開始之前想打個預防針,因為我有點懶且內向不太擅長攀談,以至於這一篇文章並不會讓讀者有跟「某某大神聊過之後獲得什麼啟發、聽了某場演講技術突然暴增一甲子」這種雞湯文的感覺,就只是很單純主觀地描述我的感受。
由於同事 B 參加過很多次 MOPCON 了,不免俗地問了一下要帶什麼裝備去比較好。在我實際體驗後確實所言不假,像是:
再來是決定想聽些什麼內容, Day 2 的議題反覆的刷了幾遍之後硬是排出了一份跟自己比較有關係的內容,在這邊做個紀錄。
這樣行程跟裝備都決定了,靜待活動當天。
果然是南台灣最大的科技年會真的是人山人海,也很容易認親!望眼看去很容易就認出一些看起來很眼熟的人,但我生性害羞又怕尷尬,原諒我沒有上去 Say Hello 了。
因為個人背景的關係,技術底子相對的沒那麼深厚,舉例來說第一場的議題講的內容其實大部分時間我幾乎都處於 204 No Content
狀態,意思是我有接收到,但也僅是有接收到而已,並沒有激起什麼化學變化。
而後面幾場議題也或多或少有類似這樣子的情況出現,我相信每位聽眾的技術水平肯定也是參差不齊,所以我合理的推斷,跟我有差不多症狀的人大有人在。
而另一方面,技術好的人不見得是好的講者,反之亦然。
合理且理性的分析後(?),肥宅如我不如下午茶吃到飽、珍奶冰棒吃到頭痛好了。
其實我真的吃了兩支冰
就我自己的觀點來說,這場聚會的目的其實並不是提升聽者的技術力,畢竟聽眾水準參差不齊,以這一點切入的話,講者要怎麼準備內容呢?
所以內容肯定是不會太深入的,而太深入的技術肯定也不會在這種場合談,取而代之的內容是講者對於這些事情的看法或經驗談。
基於這樣子的推論,我把今天所有聽不懂的東西都當成關鍵字
或者稱為知識點
,而埋下這些東西有什麼好處呢?
而聽得懂的內容,也值得思考為何該名講者會有這樣的觀點、看法,或許這就是為什麼今天他在台上,我們在台下的原因。
以上就是我 MOPCON 2019 很佛系的參加心得,若看完這些還是不清楚到底明年該不該一起參與 MOPCON ,只能說身為一個工程師就是一直不斷的 Try & Error
,都沒親自試過要如何怎麼知道好不好?
更何況門票也只是區區的 800 ,這個錢比課一單還要少。
真的覺得虧,頂多下午茶吃到飽,沒事的。
身為工程師的你,平常通靈就夠辛苦了,沒必要連這種地方也通靈,你說是吧?
]]>當我們辛苦地做完網站後,總是會部屬到正式的伺服器上,而因為使用了 SSR 的技術, GitHub Pages 提供的靜態網頁服務已經不能滿足這個需求了,所以可以使用另一個常見的服務 Heroku 。
這個部分不會著墨太多,僅會說明必要的部分,在此預設已經註冊一組 Heroku 的帳號了。
如果要將專案部屬到 Heroku 可以使用 Heroku CLI 來達成,下載安裝後可以開啟終端機輸入 heroku -v
確定版本號。
Heroku 部屬的過程其實也不算太難,它有點像是一般開發過程時我們將專案推到 GitHub 般,所以是相當熟悉的。
但我們目前並不能直接將專案直接推上 Heroku ,因為還需要進行一些必要的調整,不然它會死給你看。
Procfile 只是一個文件告訴 Heroku 要啟動你的應用程序需要執行什麼命令。所以需要在裡面填入:
1 | web: npm run start:heroku |
接著就可以保存退出了。
接著要在 scripts
中新增一些指令:
1 | "start:heroku": "node dist/server", |
postinstall
是 Heroku 在 npm 完成每個構建的所有依賴項安裝後自動運行的命令。這麼一來前置作業都完成了,可以準備部屬到 Heroku 囉!
在部屬之前,務必要確認所有的異動都已經被 commit ,這樣待會推到 Heroku 才會正常哦。
部屬步驟
heroku login
,按照指示操作並且透過瀏覽器登入heroku create
,新增一台 Heroku 主機從這個步驟得知我們建立了一台叫 radiant-refuge-87634
的 Heroku 主機。
git push heroku master
,將專案推到主機。這個步驟會耗費比較多的時間,如果沒有什麼意外的話通常是會 push 成功。
推到 Heroku 的過程中其實是遇到蠻多困難的,幸好國外有人整理文章供後人學習:
程式碼
其實這兩篇文章最初就是自己在想:如果我幫朋友以現代的網頁技術架一個網站,那麼我可以怎麼做的想法作為出發點。
希望這兩篇簡單的筆記可以幫助到有同樣需求的人 & 未來金魚腦忘記的自己。
]]>使用 Angular 建立的網站是屬於 SPA (Single-Page Application) 單頁應用,是一種網路應用程式或網站的模型,它通過動態重寫目前頁面來與用戶互動,而非傳統的從伺服器重新載入整個新頁面。
雖然現在 Google 的爬蟲已經可以看得懂 SPA 架構的網站,但其他的搜尋引擎不見得看得懂,因此使用 SSR (Server Side Render) 技術來輔助網站的 SEO 還是必要的。
關於更詳細的名詞解釋可以參考 前後端分離與 SPA - Huli ,本文不會有太多著墨。
其他前端主流框架如 React 或 Vue 對於 SPA 不利於 SEO 都有相關的解決辦法,如:
而 Angular 的解決方案就是使用 Angular Universal了。
在早期 Angular 要使用 SSR 技術似乎是相當麻煩的要修改很多地方,但現在卻可以使用少少的幾行指令就達成!而且幾乎是手把手照著 Angular 官網的步驟做,是不是相當友善呢~
在終端機內輸入 ng new [projectName]
就可以建立起一個 Angular 的專案,這個示範中建立了 SSRDemo 專案。
在終端機輸入以下指令:
1 | ng add @nguniversal/express-engine --clientProject SSRDemo |
如此一來 Angular CLI 就會幫這個專案產生必要的檔案如: app.server.module.ts
。
不難看出 Angular CLI 幫我們做了相當多的事情,在早期這可是需要自己手動進行的而且不太容易。
所有的準備工作都已經就緒,可以在終端機輸入以下指令啟動伺服器:
1 | npm run build:ssr && npm run serve:ssr |
會得到乍看之下與尚未使用 SSR 技術前一樣的結果:
但實際上原始的程式碼內已經大不相同了!
不難看出有使用 SSR 技術的原始碼內多了不少內容,而這些內容就是要給搜尋引擎爬蟲看的。
在 Universal 應用中 HTTP 的 URL 必須是絕對位置,只有這樣 Universal 的 Web 伺服器才能處理那些請求。
這意味著當執行在伺服器端時,要使用絕對 URL 發起請求,而在瀏覽器中,則使用相對 URL。
在官網的 Angular 教學 (英雄範例) 中的服務都把請求送到了相對的 URL ,所以為了使其正常運作,需要額外建立 HttpInterceptor
,令其使用絕對 URL 發起請求。
建立 universal-interceptor.ts
1 | import {Injectable, Inject, Optional} from '@angular/core'; |
調整 app.server.module.ts
1 | import {HTTP_INTERCEPTORS} from '@angular/common/http'; |
現在當伺服器發起每個 HTTP 請求時,該攔截器都會被觸發,並把請求的 URL 替換為由 Express 的 Request 物件給出的絕對位置。
一開始還沒實作時看了蠻多篇文章的,但距今都有一定的時日了,在前端技術迭代的如此迅速的情況下,一篇幾年前的文章參考價值就不那麼高了,但對於了解整個脈絡還是很有幫助的,因此還是列出大致上看過那些文章:
程式碼
結束了本篇的學習後,下一篇文章我們將試著將這份專案正式部屬到 Heroku 讓練習更加完整。
]]>前陣子因為專案需求,所以開始研究如何實踐 Drag & Drop 進行拖曳上傳,由於 Team Leader 的要求,希望目標是能達到跟 Google Drive 一樣的操作體感,於是遇到第一個問題,「Google Drive 允許使用者取消上傳欸,啊我們要怎麼實作取消上傳」?
模仿的第一步就是先觀察,所以到 Google Drive 實際操作一次檔案上傳的流程,並且透過開發者工具觀察。
雖然從這個角度仍無法得知 Google 這段期間在背後做了什麼,但是至少有了可以追查的線索:
cancel
換句話說可以接著從「如何取消 or 中斷 HTTP 的 Request」開始著手。
如果多嘗試這些關鍵字,最後會搜尋到再 Xhr
內有個 .abort
方法可以使用。
有了可用的方法,接下來就是試著實作看看了,由於目前沒有後端開 API 給我們測試,在本機端使用 Json-Server 可能回應速度太快來不及按取消就完成了,於是理想方案可以串 RANDOM USER 這支好用的 API 來測試想法。
Code
1 |
|
1 | const sendRequest = document.querySelector('#sendRequest'); |
至此,足以驗證 xhr.abort()
確實是可以中斷請求的發送,那麼如果是用在檔案上傳呢?伺服器真的會因為前端中斷的 HTTP 的請求就停止檔案上傳嗎?
為了驗證想法,我們必須自己實作一個簡易的 node.js 伺服器。
基於「方法嘗試階段」最後的結論,必須實作一個伺服器才能滿足我的好奇心。
但是我又不太熟 node.js 又有點懶,因此在這裡感謝 Ray 幫我產出一個簡易的模板。
至於 node.js 該怎麼實作檔案上傳以及怎麼開 API 我就不多著墨了,畢竟這不是主要的內容。
於是一番努力後,我們有了最基本的 code ,試著調整並搭配剛才的程式碼。
code
1 | const express = require('express'); |
很快地發現了第一個問題:
總結現況得出以下結論:
於是乎我們又回到了方法嘗試階段,老實說這裡我嘗試了很多方法,仍然無法得知前端何時按下取消請求
,後來不得已把問題整理乾淨後上前端社群發問。
最後得知我要的答案或許在 multer 的 issue 內
當然這個過程是不斷的反覆測試、修改的,最終我也實作檔案上傳比較常見的流程:
temp
資料夾uploads
資料夾另外再進行檔案刪除時,也碰到一個雷:
當使用 fs.unlink() 進行檔案的刪除時:
截自連結部分敘述
unlink() deletes a name from the filesystem. If that name was the last link to a file and no processes have the file open, the file is deleted and the space it was using is made available for reuse.
所以我們又有了新的實作目標,接著又回到實作階段了!
基於上一階段的結論,所以我使用社群內前輩的建議作法,上傳後先放在 temp
資料夾,等到確定上傳完成才移動到 uploads
資料夾,而 temp
資料夾就可以用各種做法定期清除或者自然等伺服器重啟清除。
而我最終版本的 code 如下:
node.js
1 | const express = require('express'); |
前端部分
1 | const sendRequest = document.querySelector('#sendRequest'); |
特別要注意的是,這邊的 code 終究只是我拿來驗證想法而寫的 code ,因此有很多狀況沒有考慮到,所以不建議直接把這段 code 直接複製拿去用。
其實做到這邊已經功德圓滿了~我想知道的都已經知道了,但我們專案都是使用 Angular 寫的,那麼再 Angular 該如何取消請求呢?
再 Angular 專案中會使用 .subscribe()
方法來訂閱某個 API 的結果,而要取消請求則可以使用 .unsubscribe()
方法,具體實作如下:
1 | <input type="file" id="file-uploader" name="file" (change)="selectFile($event)"/> |
1 | import { Component, ViewChild, ElementRef, OnInit } from '@angular/core'; |
這邊運行的結果會跟上一段的結論一樣~就不反覆截圖了。
其實寫程式我覺得就是一直重複「方法嘗試階段」以及「實作階段」。
而這個過程中我覺得最累也最有趣同時也最傷腦筋的就是「方法嘗試階段」,只要過了這個階段,後面的「實作階段」就相對單純很多。
而當「實作階段」結束後肯定會玩看看,如果出現預期外的結果,就回到「方法嘗試階段」,周而復始。
]]>因為專案需求所以有稍微用到一些密碼學的部分,前幾天團隊內的同事在研究的時候,我也好奇的上前圍觀,但才疏學淺,居然脫口說出 base64 是加密的一種然後被噹爆,超想躲進土裡的啊啊啊!!
所以為了雪恥,決定稍微研究一下 RSA 非對稱式加密,以及整理一下 Base64 只是編碼不是加密這件事情。
在說明之前可以先從 WIKI 上了解 Base64 是什麼樣的東西。
懶人包: Base64 是編碼的一種,並沒有 Base64 加密這回事。
以下節錄自 WIKI:
Base64 是一種基於 64 個可列印字元來表示二進位資料的表示方法,常用於在通常處理文字資料的場合,表示、傳輸、儲存一些二進位資料,包括MIME的電子郵件及 XML 的一些複雜資料。
而如果我們有仔細看 WIKI 的話,會發現這份文檔皆是使用 Base64 編碼而非 Base64加密,從這邊其實就可以看出一些蛛絲馬跡。
這三者無論是用中文、英文來看都有相當大的差異:
到底為什麼很多人會把這三種完全不一樣的東西都當成加密呢?
主要是因為透過這三種方式處理過後的資料,都會長的跟原本不一樣,一般人無法直接用肉眼辨別,就會讓人覺得像是被加密處理過的天書。
然而並不是把資料變成人看不懂的東西就可以稱為加密,身為一個工程師,如果搞不清楚箇中差異是會被笑的。 (就跟我被噹爆一樣…)
所幸這部分已經有前輩整理好了,以下敘述引用自 m157q 前輩的部落格 - 如何區分加密、壓縮、編碼,感謝前輩辛苦整理的資料。
對稱式加密 (Symmetric Encryption):
然而對稱式加密的安全性以及在實際應用上不夠理想,於是出現了安全性更高,應用範圍更廣的非對稱式加密 (Asymmetric Encryption)
非對稱式加密 (Asymmetric Encryption):
兩者各有各的優缺點,所以實際應用上通常都是視情況而定。
常見演算法
根據這一段加密的介紹,再回頭過來想使用 Base64 的場景,可推出如下結論:
使用 base64 的時候不需要密鑰,而且任何人編碼的 base64 訊息,誰都可以經過 base64 解碼回來,所以 base64 不是加密。
編碼牽涉的範圍非常廣,如:
而 Base64 屬於字元編碼的部分,而編碼的特性為:
常見演算法
由於看同事們那個時候再弄的範例有公鑰 (public Key) 、私鑰 (private Key) 之分,藉由上面的介紹可知這是一種非對稱式的加密方式,因此更進一步 Google 後,決定使用 node.js 來簡單實作看看 RSA 非對稱式加密。
有關 RSA 非對稱式加密的部分,可以參考:
技術實作方面,得知 Node.js 已經有相關的加密包 (crypto) 可以使用,於是可以針對這個關鍵字搜尋,得出不少可用資源,整理如下:
直接上完整程式碼
1 | const crypto = require('crypto'); |
這段程式主要是按照希望的格式產生公、私鑰之後,接著使用公鑰加密 message
變數的字串,最後在使用私鑰解密。
但因為私鑰的部分,我額外的上了一層加密,所以在進行解密的時候需要額外帶入私鑰的密碼才可以順利進行。
]]>時間過得很快,不知不覺的我就滿月了。期間不斷進行的是與團隊成員的磨合,而之前提到的 Side Project 也如期地進行中。
畢竟是優先度較低的 Side Project ,所以對於其他團隊成員來說,都是運用比較零碎的時間來做,主要還是處理主力專案。
而我因為還沒正式接觸到公司專案,自然重心是放在這個 Side Project 上。
如上面提到的,這是個團隊的 Side Project ,意味著可以自由地玩很多新東西。
Team Leader 決定導入 prettier 並且提交 commit
時觸發 prettier
的自動格式化,最後在 GitLab 上 進行 CICD 的檢查,達成團隊的 Coding Style 一致。
然而在開發習慣上,因為之前都是單兵作業,所以比較少顧慮到可能會有其他團隊成員來維護同一份 Code 的情形。
這也導致了寫某些程式時缺少了比較長遠的思考,很多部分都是後來發現「不能這樣寫、這麼寫不夠好」而又回頭修改。
團隊基本上是使用 Angular 搭配 TypeScript 進行開發的,而我之前在學習前端時都是使用 JavaScript ,在思維上有一點差異,這也是我目前需要學習與磨合的地方。
在敏捷開發 Sprint 的第一周,我負責的是串接某一支自己 Mock 出來的 API,裡面的內容雖然我有整理好一份文件,但呼叫 API 後的內容仍然只有我一個人知道,團隊的其他成員完全不知道呼叫這支 API 後會回傳什麼結果,導致後續維護不方便。
因為:
但這對之前的我來說是一件很正常的事情:
接 API 如果不知道內容是什麼,印出來不就好了嗎?或者看文件也行,上面都有寫。
這樣的確可以,但對團隊來說,這還不夠好。
所以實作一個 class 並且透過 TypeScript 將該變數型別設定為它,如此一來就可以在接到 API 傳來的資料後,使用「.」看到這個物件下有什麼屬性、方法能使用。
搭配適當的命名就可以讓維護的人明白這支 API 會回傳的內容有哪些。
而這也只是與團隊磨合的一小部分而已,我還必須持續透過這個 Side Project 了解到許多團隊協作的眉角。
在開發上當然也遇到了一些困難,自己歸納後最主要的原因果然還是:
在 Sprint 的第一周時,我先行使用原生的 JavaScript 快速的把原型給搭建出來,像是 Mock API 、 GitLab API 的串接整理、圖表繪製等等…,過程都蠻順利的,也很少麻煩到團隊內的前端同事。
但在實際轉換成 Angular 時,卻遇到了不少自己想不透的問題需要請教同事,這讓我有點不好意思。
像是上面提到的協作問題、舊版本
ng2charts
的 Bug 、寫測試、還有一些特殊名詞 & 觀念問題。
最終總算是在交作業的前一天完成了,幸好同事也不厭其煩地與我一同排除掉這些問題,真的是非常感謝。
但我也希望自己能夠早日解除這種狀態,實在是有點擔心自己試用期不會過 (汗)
其實還有很多族繁不及備載的芝麻蒜皮小事,像是命名與程式寫完之後該怎麼調整才能讓結構看起來易讀,這些都是值得我去努力的東西。
然後也借了一本無瑕的程式碼 番外篇:專業程式設計師的生存之道,書名看起來就像是我這輩子絕對不會看的東西,但我終究是借來看了。
那個當初連 「Hello World」 印出都有困難的人去哪了?
一個人可以改變這麼多,每當回頭想起,我還是覺得很不可思議。
]]>在專案的開發中有些時候為了應付特殊的需求會安裝一些第三方的套件,避免重複造輪子。而這第三方套件很可能會是 CSS 框架或者是一些 很方便的 library 等等,而我們要如何在 Angular 環境中使用它們呢?本篇將介紹 Bootstrapt4 以及 json-server 如何再 Angular 的環境下使用。
都已經是 No.51 了,我想對於建立新的 Angular 專案並不是什麼太困難的事情。
執行 ng new pluginDemo
,建立起本次範例的專案。
接著來到 Bootstrap4 的官方,看看如何取得 Bootstrap4 吧!
Bootstrap4 提供下列使用方式:
以這個情境來講,一定是整個專案都希望可以用 Bootstrap4 的東西,因此可以在 src/styles.scss
這裡做一些調整,這隻檔案會影響到整個 Angular 專案的 CSS 。
src/styles.scss
1 | /* You can add global styles to this file, and also import other style files */ |
如果要完整的使用 Bootstrap4 所有的東西,也必須引入相關的 js 檔案才行, js 檔案可以在 index.html
內引用。
1 |
|
接著隨意地找 Bootstrap4 官網上的範例,貼在 appComponent 的 Template 上,測試有無效果。
例如貼上 modal 的範例程式碼
這部分因為 npm 或 yarn 使用的方式差不多,所以我就只使用 yarn 來示範。
先移除方才所有引入的 js 檔以及 import 進 src/styles.scss
的檔案。
接著可以參考 Package managers 得知可以使用 yarn add bootstrap
取得 Bootstrap4。
接著我們一樣要引用這些下載好的檔案。
可以看到透過這種方式,使用 Bootstrap4 的彈性就更大了,可以單獨選擇想要使用 Bootstrap4 的某部分。但在這裡為了示範,選擇 bootstrap.scss
即可。
接著還需要把 Bootstrap4 本身依賴的 js 檔案也引入才可以正常使用所有的功能,但這裡還有個前提是:
Bootstrap4 也依賴著 jQuery ,因此我們還需要額外使用 yarn 下載 jQuery ,才能完整使用 Bootstrap4 。
以下是 Bootstrapt4 中有使用到 JavaScript 控制的元件:
來到 jQuery 官方得知可以使用 yarn add jquery
下載。
這部分的調整則必須到 angular.json
內的 scripts
設定了。
1 | "scripts": [ |
這邊使用 bootstrap.bundle.min.js
原因是 Bootstrap4 官方表示 「Our bootstrap.bundle.js and bootstrap.bundle.min.js include Popper」,也就是只要引入這隻檔案即可。
接著運行開發伺服器確認結果吧!
會使用到這個功能是因為有時候專案開發時,配合的後端不一定會很快就提供 API 給前端串接,為了不浪費時間,前端可以自行透過 json-server 快速 Mock 出一個 API 供自己測試使用。
而如何在一般的環境下使用 json-server 也已經再另外一篇介紹過,因此這裡就不會這麼仔細介紹,僅介紹如何安裝使用。
json-server GitHub 官方顯然地並沒有提供 yarn 方式的下載,只能使用 npm 。
官網提供的安裝方式是全域的
1 | npm install -g json-server |
但如果想被記錄在 package.json 下的話可使用
1 | npm install json-server --save-dev |
如 json-server 官方的起手範例,建立一個 db.json
檔案,並貼上如下內容:
1 | { |
接著再 package.json 的 script
內加入指令,方便使用:
1 | "scripts": { |
先運行看看吧~
雖然是成功了,但很快的我們發現了第一個問題:
ng serve
了,怎麼辦?在這裡我們要額外安裝一個叫做 concurrently 的套件,它允許多進程以異步並行而非順序同步方式運行。
安裝方式
1 | npm install concurrently --save-dev |
安裝完成後,打開 package.json 進行指令的設定:
1 | "scripts": { |
最後,實際運行看看吧~
運行成功~這樣子就可以同時運行 Angular 的開發伺服器以及 json-server 囉~
參考資料
原始碼 - GitHub
]]>好一陣子沒有寫 Blog 了,原因也是因為初階的 Angular 學到一個段落了,團隊的 Leader 決定讓隊伍內的成員們以敏捷開發的方式跑看看一個 Side Project ,順便讓我學習如何與團隊合作一個專案。
而這篇文章主要是簡單記錄自己如何使用 json-server 快速 Mock 一個 API 讓自己串接,並且使用 chart.js 繪製出堆疊長條圖。
來到 json-server 的 GitHub 查看如何使用。
使用步驟如下:
npm init
建立 package.json 檔npm install -g json-server
安裝 json-server接著新增 db.json
檔,內容可先複製官網範例進行參考:
1 | { |
輸入 json-server db.json
運行 json-serve ,等候呼叫 API
db.json
檔就是當呼叫 API 時會傳回那些資料的檔案,舉例來說:
http://localhost:3000/posts
{ "id": 1, "title": "json-server", "author": "typicode" }
這邊因為之後要使用 chart.js 進行圖表繪製,所以我另外有準備資料,因為跟工作有關就不貼上來了。
大多時候 json-server 的預設範例是滿足不了我們的。
舉例來說,我想要 Mock 的 API 路徑希望是:
1 | /api/v1/issues/analysis?gid=:gid&sDate=:sDate&eDate=:eDate |
這時候就需要自訂路由了!
新增 route.json
檔案
接著在裡面貼上官網的範例:
1 | { |
如上設定後,對應結果如下:
/api/posts
# → /posts/api/posts/1
# → /posts/1/posts/1/show
# → /posts/1/posts/javascript
# → /posts?category=javascript/articles?id=1
# → /posts/1找出規則後,修改 route.json
變成我們要的結果。
1 | { |
為了更方便使用,也可以自行配置 json-server 。
新增 json-server.json
並且在裡面輸入:
1 | "port": 5000, //自定 port |
因為只有使用到這些配置,更多配置請參考官方 GitHub 文件。
配置已經完成了,最後在 package.json
中加入以下指令:
1 | "scripts": { |
之後只需要執行 npm run mock
指令即可運行以上這些配置囉!
參考文件
先到 chart.js 官方了解如何使用,發現有兩種使用方式:
為了方便,這裡我使用了 CDN 的方式使用 chart.js
由於我想畫的是堆疊柱狀圖,但我們依然可先參考官方的起手範例,先行建構一個範本。
1 | <canvas id="myChart"></canvas> |
1 | var ctx = document.getElementById('myChart').getContext('2d'); |
官網文件寫得十分詳細,但因為是英文,所以需要花一點時間找相關的方法、屬性如何使用。
最後參考了這篇文章得知如何把柱狀圖堆疊起來,修改程式碼後順利完成本次目標~
因為資料涉及公司,所以就不放上程式碼以及相關圖片了。
]]>這是我參考官方文件進行 Angular 表單實作時遇到的問題,當我建立一份 form 表單並且在裡面的 input 內添加 required ,企圖使用原生的 HTML5 驗證卻沒有生效,最後找到解決辦法,因此特別寫下這一篇。
這是一個非常單純的 HTML5 的驗證:
CodePen
1 | <form> |
這會驗證 form 表單內的 input 是否有值,若為空值則無法提交。
我嘗試要在 Angular 內使用,但卻沒有觸發驗證。
1 | <form> |
參考以下文章解決
而從文章得知,似乎是自從 Angular 4 後,預設把 HTML5 的驗證關閉了,需要手動在 form 元素上添加
ngNativeValidate
將其開啟。
1 | <form (ngSubmit)="onSubmit()" ngNativeValidate> |
承上篇,接著使用 FormBuilder 重構程式碼。
當需要與多個表單打交道時,手動建立多個表單控制元件例項會非常繁瑣。
FormBuilder 服務提供了一些便捷方法來產生表單控制元件,在幕後也使用同樣的方式來建立和返回這些實例,只是用起來更簡單。
接下來會重構 ProfileEditor 元件,用 FormBuilder 來建立這些 FormControl 和 FormGroup 實例。
從 @angular/forms 包中匯入 FormBuilder 類。
1 | import { FormGroup, FormControl, FormBuilder } from '@angular/forms'; |
FormBuilder 是一個可注入的服務提供商,它是由 ReactiveFormModule 提供的。
只要把它新增到元件的建構函式中就可以注入這個依賴。
1 | constructor(private fb: FormBuilder) { } |
FormBuilder 服務有三個方法:
這些方法都是工廠方法,用於在元件的 class 中分別產生 FormControl 、 FormGroup 和 FormArray。
所以可以使用 group 方法建立 profileForm 控制元件。
而目前 profile-editor.component 的 class 是這樣的
1 | import { Component, OnInit } from '@angular/core'; |
不難看出與先前手動建立 FormControl 、 FormGroup 十分相似,而且省下了非常大量的 new 。
每個控制元件名對應的值都是一個陣列,而陣列中的第一項是其初始值。
可以只使用初始值來定義控制元件,但是如果控制元件還需要同步或非同步驗證器,那就在這個陣列中的第二項和第三項提供同步和非同步驗證器。
運行看看重構的結果吧!
表單驗證用於驗證使用者的輸入,以確保其完整和正確。
那麼該如何把單個驗證器新增到表單控制元件中,以及如何顯示表單的整體狀態呢?
響應式表單包含了一組內建的常用驗證器函式。
這些函式接收一個控制元件,用以驗證並根據驗證結果返回一個錯誤物件或空值。
1 | import { Validators } from '@angular/forms'; |
檢查某個欄位有沒有被正確的填入值是相當常見的驗證:
1 | profileForm = this.fb.group({ |
然後使用內嵌繫結觀察表單的驗證狀態。
1 | <p> |
而我們也可以搭配原生的 HTML5 驗證屬性,可以防止在 Template 檢查完之後表示式再次被修改導致的錯誤。
1 | <input type="text" formControlName="firstName" required> |
可以做得更好!像是把驗證有沒有通過的狀態繫結在提交按鈕的 disabled 屬性上。
1 | <button type="submit" [disabled]="!profileForm.valid">提交</button> |
提交按鈕被禁用了,因為 firstName 控制元件的必填項規則導致了 profileForm 也是無效的。
FormArray 是 FormGroup 之外的另一個選擇,用於管理任意數量的匿名控制元件。
像 FormGroup 實例一樣,可以在 FormArray 中動態插入和移除控制元件,並且 FormArray 實例的值和驗證狀態也是根據它的子控制元件計算得來的。
FormArray 與 FormGroup 差別在於不需要為每個控制元件定義一個名字作為 key。
因此,如果不知道子控制元件的數量,這就是一個很好的選擇。
舉例來說,我們可以加入綽號的部分,因為我們不知道綽號會有幾個。
從 @angular/form 中匯入 FormArray,以使用它的型別資訊。
1 | import { FormArray } from '@angular/forms'; |
可以透過把一組(從零項到多項)控制元件定義在一個陣列中藉以初始化一個 FormArray。
為 profileForm 新增一個 alias 屬性,把它定義為 FormArray 型別。
使用 FormBuilder.array() 方法來定義該陣列,並用 FormBuilder.control() 方法來往該陣列中新增一個初始控制元件。
1 | profileForm = this.fb.group({ |
現在 FormGroup 中的這個 alias 控制元件現在管理著一個控制元件,將來還可以動態新增多個。
相對於重復使用 profileForm.get() 方法獲取每個例項的方式, getter 可以讓你輕鬆訪問 FormArray 實例中的綽號。
FormArray 實例用一個陣列來代表未定數量的控制元件。
透過 getter 來訪問控制元件很方便,這種方法還能很容易地重複處理更多控制元件。
使用 getter 語法建立類屬性 aliases,以從父表單組中接收表示 aliases 的 FormArray 控制元件。
1 | get aliases() { |
因為 return 的控制元件的型別是 AbstractControl,所以要為該方法提供一個顯式的型別宣告來訪問 FormArray 特有的語法。
宣告 addAlias 方法來把一個控制元件動態插入到 aliases 的 FormArray 中:
1 | addAlias() { |
在 Template 中,這些控制元件會被迭代,把每個控制元件都顯示為一個獨立的輸入框。
使用 formArrayName 在這個 FormArray 例項和範本之間建立繫結。
1 | <div formArrayName="aliases"> |
使用 ngFor 指令對 aliases FormArray 提供的每個 FormControl 進行迭代。
因為 FormArray 中的元素是匿名的,所以要把索引號賦值給 i 變數,並且把它傳給每個控制元件的 formControlName 輸入屬性。
每當新的 alias 加進來時,FormArray 的實例就會基於這個索引號提供它的控制元件。
至此,我們完成了響應式表單的基礎範例練習。
]]>玩過範本驅動表單後,接著來體驗看看響應式表單吧~
以下節錄自官網敘述:
響應式表單使用顯式的、不可變的方式,管理表單在特定的時間點上的狀態。對表單狀態的每一次變更都會返回一個新的狀態,這樣可以在變化時維護模型的整體性。
響應式表單還提供了一種更直觀的測試路徑,因為在請求時你可以確信這些資料是一致的、可預料的。這個流的任何一個消費者都可以安全地操縱這些資料。
如果要使用響應式表單,就要從 @angular/forms 包中匯入 ReactiveFormsModule 。
輸入 ng g c NameEditor
建立元件。
當使用響應式表單時, FormControl
類是最基本的構成要素。
所以要在這個元件中匯入 FormControl
類,並 new
一個 FormControl
實體,把它儲存在 class 的某個屬性中。
name-editor.component class
1 | import { Component, OnInit } from '@angular/core'; |
可以用 FormControl
的建構函式設定初始值,這個例子中它是空字串。
我們可以在元件的 class 中建立這些控制元件,直接對表單控制元件的狀態進行監聽、修改和驗證。
剛才的步驟在 class 中建立控制元件後,我們還需要把它和範本中的表單控制元件關聯起來。
例如:為表單控制元件新增 formControl
繫結
formControl
是由 ReactiveFormsModule
中的 FormControlDirective
提供的,更多class 以及指令可以參考響應式表單 API
1 | <label> |
使用這種範本繫結語法,把該表單控制元件註冊給了 Template 中名為
name
的輸入元素。
這樣表單控制元件和 DOM 元素就可以互相通訊了
實際將元件運行來觀察是否正常:
剛才的步驟已經建立了一個基礎了表單控制元件,而響應式表單讓你可以訪問表單控制元件此刻的狀態和值。
可以透過元件的 class 或元件的 Template 來操縱其當前狀態和值。
可以用兩種方式顯示它的值:
value
屬性,它能讓你獲得當前值的一份快照。這邊示範方法二 - 使用內嵌繫結的方式觀察表單的值。
1 | <label> |
一旦修改了表單控制元件所關聯的元素, p 標籤內顯示的值也跟著變化了。
響應式表單還有一些方法可以用程式設計的方式修改控制元件的值:
舉例來說我們在元件的 class 內新增一個 updateName() 方法:
1 | updateName() { |
接著修改 Template 新增一個按鈕,並把剛才新增的方法綁上去。
1 | <p> |
特別要注意的是:
在這個例子中,我們只使用單個控制元件,但是當呼叫FormGroup
或FormArray
的setValue()
方法時,傳入的值就必須匹配「控制元件組」或「控制元件陣列」的結構才行。
接著來談談如何將把表單控制元件分組。
FormControl 的實例能讓我們控制單個輸入框所對應的控制元件,而 FormGroup 的實例能追蹤一組 FormControl 實例(比如一個表單的狀態)
輸入 ng g c ProfileEditor
,建立元件。
匯入 FormGroup 和 FormControl 的 class
1 | import { Component, OnInit } from '@angular/core'; |
跟剛才單個的 FormControl 蠻像的,差別在於 FormGroup 就是一個物件包著很多 FormControl 的概念,如下:
1 | import { Component, OnInit } from '@angular/core'; |
FormGroup 實例擁有和 FormControl 實例
這個 FormGroup 能追蹤其中每個控制元件的狀態及其變化,所以如果其中的某個控制元件的狀態或值變化了,父控制元件也會發出一次新的狀態變更或值變更事件。
該控制元件組的 model 來自它的所有成員,在定義了這個 model 後,你必須更新 Template ,把該 model 反映到 view 中。
profile-editor.component
1 | <form [formGroup]="profileForm"> |
就像 FormGroup 所包含的那些控制元件一樣, profileForm
這個 FormGroup 也透過 FormGroup 指令繫結到了 form 元素上,在該 model 和表單中的輸入框之間建立了一個通訊層。
藉由 FormControlName 指令把每個輸入框和 FormGroup 中定義的表單控制元件繫結起來。
這些表單控制元件會和相應的元素通訊,如果有修改,把修改傳遞給 FormGroup 。
ProfileEditor 元件從使用者那裡獲得輸入,但在實務上我們可能想要先獲得表單的值。
FormGroup 指令會監聽 form 元素發出的 submit 事件,然後發出一個 ngSubmit 事件,讓你可以繫結一個 callback 函式。
所以之後我們可以在 class 內建立一個 onSubmit() 方法,並且綁在 ngSubmit 事件上。
1 | <form [formGroup]="profileForm" (ngSubmit)="onSubmit()"> |
ProfileEditor 元件上的 onSubmit() 方法會捕獲 profileForm 的當前值。要保持該表單的封裝性,就要使用 EventEmitter 向元件外部提供該表單的值。
並且使用 console.log 觀察提交結果。
1 | onSubmit() { |
最後我們必須新增一個按鈕,並且把 type 設定為 submit 。
1 | <button type="submit" [disabled]="!profileForm.valid">提交</button> |
FormGroup 支援巢狀結構,因此我們可以建立更複雜的表單應用。
比如說可以在目前的例子中加入地址:
1 | import { Component, OnInit } from '@angular/core'; |
雖然 address 這個 FormGroup 是 profileForm 這個整體 FormGroup 的一個子控制元件,但是仍然適用同樣的值和狀態的變更規則。
來自內嵌控制元件組的狀態和值的變更將會冒泡到它的父控制元件組。
因為剛才修改了 class 內的 model ,所以 Template 也需要作出調整。
1 | <form [formGroup]="profileForm" (ngSubmit)="onSubmit()"> |
測試看看是否仍正常運作。
如果想更新部分 model 的內容而不是整個都替換掉的話,有兩種更新 model 值的方式:
setValue() 方法的嚴格檢查可以幫助你捕獲複雜表單巢狀中的錯誤,而 patchValue() 在遇到那些錯誤時可能會默默的失敗。
新增一個更新鈕,並且在 class 內新增一個 updateProfile() 方法。
1 | <button type="button" (click)="updateProfile()">更新</button> |
使用 patchValue() 方法
1 | updateProfile() { |
使用 setValue() 方法
1 | updateProfile() { |
像這樣,使用 setValue() 方法會整體性替換控制元件的值,但在這裡我故意少寫 zip 屬性,並嘗試提交。
因此如果使用 setValue() 方法就必須要把屬性全部寫上去才行。
接下來要使用 FormBuilder 進行重構,都寫在同一篇感覺篇幅太長了,因此拆開兩篇寫。
]]>介紹完 Angular 內兩種表單的不同後,接著實作看看範本驅動表單吧。
Angular 框架支援:
而實作過程中,將學會:
這個範例將:
建立專案的部分就不再贅述了。
因為每次輸入表單資料時,資料大致上都是固定的,因此可以建立一個 hero 的 class 來處理這些事情。
之後要使用時可以透過 new
將其實例化成物件後,方便我們取用。
hero.ts
1 | export class Hero { |
其中 alterEgo
屬性後面接了 ?
號,代表 alterEgo
屬性不是必須的,呼叫建構函式時這個參數可以省略。
也就是說之後我們可以這樣來建立一個新英雄:
1 | let myHero = new Hero(1, '超級牛', '超級牛來拯救雷', '牛妹妹'); |
輸入 ng g c HeroForm
建立元件。
接著在 class 內寫一些東西:
hero-form.component
1 | import { Component, OnInit } from '@angular/core'; |
修改 app.module.ts
1 | import { BrowserModule } from '@angular/platform-browser'; |
在這裡要匯入:
修改 app.component.ts
1 | <app-hero-form></app-hero-form> |
別忘了在根元件內把這個子元件載入。
修改 hero-form 的元件範本 (Template)
1 | <div class="container"> |
在這裡添加了兩個欄位:
可以發現到這一小段 HTML5 的程式碼,裡面用了一些 Bootstrap4 的 className 。
但這不是必需的,這裡只是因為美觀所以想要使用。
可以透過修改 src/styles.css
引入 Bootstrap4。
1 | /* You can add global styles to this file, and also import other style files */ |
接著運行開發伺服器,觀察一下目前的樣子。
添加能力選單
還記得我們在 HeroFormComponent 的 class 內寫的 powers
陣列嗎?
接下來要使用 ngFor 建立一個下搭式選單。
1 | <div class="form-group"> |
基礎的表單已經成形,接著我們要將資料雙向繫結到 Input 上。
1 | <div class="container"> |
做到這邊的時候,遇到一個小小的阻礙:
往 form 標籤中加入 #heroForm="ngForm"
heroForm 變數是一個到 NgForm 指令的參考,它代表該表單的整體。
NgForm 指令為 form 增補了一些額外特性:
現在我們已經可以透過雙向繫結修改資料了,但是還可以透過 ngModel 知道更多資訊:
ngModel 指令不僅僅追蹤狀態。它還使用特定的 Angular CSS 類來更新控制元件,以反映當前狀態。 可以利用這些 CSS 類來修改控制元件的外觀,顯示或隱藏訊息。
在姓名的 input 標籤上新增名叫 spy
的臨時範本參考變數,然後用這個 spy
來顯示它上面的所有 className。
1 | <div class="form-group"> |
既然可以追蹤 input 上的 className 狀態,我們就可以自訂一些視覺反饋效果。
hero-form.component.scss
1 | .ng-valid[required], .ng-valid.required { |
甚至可以透過控制 hidden 屬性,自訂一些錯誤訊息。
1 | <div class="form-group"> |
範本參考變數可以訪問 Template 中的 input ,建立 name 的變數並且賦值為 ngModel
。
當這個 input 的驗證是有效的 (valid) 或全新的 (pristine) 時,隱藏訊息。
全新的 (pristine) 意味著從它顯示在表單中開始,使用者還從未修改過它的值。
這樣就完成了視覺反饋效果。
裏人格因為是非必填,所以不需要做處理;下拉式選單則因為設計的關係一定會有值,所以也不需要處理。
在表單的底部放置新增英雄的按鈕,並把它的點選事件繫結到元件上的 newHero 方法。
hero-form.component.html
1 | <button type="button" class="btn btn-primary" (click)="newHero()">新增英雄</button> |
hero-form.component.ts
1 | newHero() { |
觀察看看結果~
使用瀏覽器工具審查這個元素就會發現,這個 name 輸入框並不是全新的。
所以跑出了錯誤提示訊息,但這樣不正確,因為我們並不希望按下新增英雄時跑出錯誤視窗。
發生預期之外的原因是:
所以必須在 newHero() 後補上 heroForm.reset() 重置表單狀態。
因此目前 Template 內的程式碼是這樣的:
1 | <div class="container"> |
填表完成之後,使用者應該要能提交這個表單。
目前這個表單的提交按鈕位於底部,並沒有在這顆按鈕上綁定任何的點擊事件,但因為有特殊的 type 值 (type=”submit”),所以會觸發表單提交。
但現在這樣僅僅觸發表單提交是沒用的。
要讓它有用,就要把該表單的 ngSubmit 事件屬性繫結到英雄表單元件的 onSubmit() 方法上:
1 | <form (ngSubmit)="onSubmit()" #heroForm="ngForm"> |
這樣就可以在按下提交鈕後觸發寫在 class 內的 onSubmit() 方法了。
可以進一步的把表單的總體有效性透過 heroForm 變數繫結到此按鈕的 disabled 屬性,這樣能讓使用者明白如果沒有填寫姓名是不能提交的。
1 | <button type="submit" [disabled]="!heroForm.form.valid" class="btn btn-success">提交</button> |
在我們按下提交後,可以將表單利用先前設置好的屬性 submitted
來控制隱藏或顯示。
1 | <div class="container"> |
一開始屬性 submitted
為 false
,顯示輸入表單
submitted
修改為 true
submitted
修改為 false
,隱藏提交後的區塊並顯示輸入表單。如此我們就完成了範本驅動表單 (Template-Driven Forms) 的簡單範例。
]]>用表單處理使用者輸入的資料是許多常見應用的基礎功能,像是使用者登入、修改資料、建立資料等等。
而 Angular 提供了兩種不同的方式透過表單處理使用者的輸入:
這些名詞看起來相當陌生,實際上用起來最簡單的會是範本驅動表單,因為它的使用方式相當直觀。
然而,根據官方文件的敘述,響應式表單的優點是:
以上這三點特性都比範本驅動表單要強,如果這個表單是專案內相當重要且複雜的部份,推薦使用這種方式來建立表單。
範本驅動表單的優點就是易於使用,很容易就能在目前的 Angular 應用中添加一個簡易的表單,像是使用者的登入。
白話來說,如果需求的表單功能相當簡易、邏輯不複雜,可以考慮使用範本驅動表單。
以下為官方標註的差異
具體來說該用哪種呢?
實際上這沒有絕對的好壞,還是得看使用情境。
倘若使用的情境既不需要寫測試、需要用到表單的地方邏輯又相當簡單,那就可以使用範本驅動表單搞定;相反的,若是相當複雜那就可以考慮使用響應式表單了。
接下來試著以範本驅動表單的方式來建立一個表單吧~
]]>也有一種狀況是:依賴的服務元件資料的取得,是透過呼叫 API 等待伺服器吐資料的非同步行為,那麼這又該如何進行測試呢?
改寫上一個範例,當點選 welcomeComponent 元件內的登入按鈕時,會以 .subscribe()
的形式觸發 user 服務元件的 getData()
方法取得資料,最後顯示出歡迎提示,並且停用登入按鈕。
welcome.component 的 Template
1 | <h3 class="welcome" *ngIf="data.isLoggedIn"><i>{{data.user}},{{data.message}}!</i></h3> |
welcome.component 的 class
1 | import { Component, OnInit } from '@angular/core'; |
user.service
1 | import { Injectable } from '@angular/core'; |
這個範例測試的重點是:
login()
時,方法真的有被呼叫於是我們可以像先前一樣,使用 jasmine.createSpyObj()
產生一個假的 getData()
方法,並且預先建立好一組假的資料 - data
。
welcome.component.spec
1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; |
前置作業準備完畢,開始撰寫測試吧。
login()
時,方法真的有被呼叫1 | it('觸發 Click 事件後,是否有正常呼叫 getData() ', () => { |
第二行的意思是,當假的 getData
方法被呼叫時必須回傳一個觀察者物件 (Observable) 。
因為第三行觸發元件內的 login
方法時
getData()
被觸發了,並且使用 .subscribe()
方法訂閱getData()
是假造的替身,所以必須回傳一個觀察者物件才可以使用 .subscribe()
,否則會出錯導致測試失敗isLoggedIn
為 true
時,登入按鈕為 disabled
1 | it('未登入時元件的渲染', () => { |
而這部分測試的關鍵在於「順序」,像是:
component.data = data;
當我們把假資料重新賦值給元件內的 data
後fixture.detectChanges();
重新進行資料與元件間的繫結fixture.nativeElement.querySelector()
找到指定目標在這個範例內,我並沒有實際的呼叫 API ,而是在 user 服務元件內透過 setTimeout()
模擬呼叫 API 時等待伺服器的時間。
而如果也想在測試過程中模擬這一段情境的話,可以使用 fakeAsync()
。
1 | it('非同步測試渲染情形', fakeAsync(() => { |
這個測試使用了 setTimeout()
方法,令 data
物件內的屬性三秒後變更,並且重新賦值給元件內的 data
屬性,最後重新繫結。
而這個測試另一個關鍵是 tick() , tick()
函式接受一個毫秒值作為參數(如果沒有提供則預設為 0)。
該參數表示虛擬時鐘要前進多少,也就是說:
setTimeout()
如果時間設置 3000 ,那麼 tick()
也要設置 3000這樣才能正確取得資料。
]]>至此完成了對元件的測試。
前一篇大致理解了如何測試一個單純的元件或是帶有繫結的元件。但在實務的應用上,元件經常依賴著其他服務元件,因此這個例子要實作的範例是 - 如何測試一個帶有依賴的元件?
輸入 ng g c welcome -m app
在產生 welcome 元件後將其註冊到 app.module 內。
輸入 ng g s user
產生 user 服務元件
到 app.module 註冊 user 服務元件
1 | import { BrowserModule } from '@angular/platform-browser'; |
基本的檔案建立完成後,接著就是撰寫程式碼了。
user.service
1 | import { Injectable } from '@angular/core'; |
在服務元件內建立 isLoggedIn
屬性以及 user
物件,這是待會要提供給 welcomeComponent 的內容。
welcome Template
1 | <h3 class="welcome"><i>{{welcome}}</i></h3> |
welcome class
1 | import { Component, OnInit } from '@angular/core'; |
最後把 app-welcome 標籤加到 app.component.html 。
1 | <app-welcome></app-welcome> |
運行開發伺服器,看看執行結果。
在這個範例裡 WelcomeComponent 依賴著 user 服務元件的資料,接收到資料後根據 isLoggedIn
的狀態決定顯示的語句。
welcome.component.spec
1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; |
這裡跟之前練習有依賴關係的服務元件的測試時有點像,我們在 TestBed.configureTestingModule 內宣告了:
被測試的元件不一定要注入真正的服務,可以是個模擬或偽造出來的資料。
這裡的主要目的是測試元件,而不是服務。
有些時候,服務元件可能連自身都有問題,不應該讓它干擾對元件的測試。
注入真實的 UserService 有可能很麻煩,像是:
這樣會很難處理這些行為,所以建立和註冊 UserService 替身 (userServiceStub) ,會讓測試更加容易。
我們製作了一個 UserService 的替身 - userServiceStub
,那麼該如何取用它呢?
Angular 的注入系統是層次化的。
可以有很多層注入器,從根 TestBed 建立的注入器下來貫穿整個元件樹。
因此這邊有兩種做法:
第一種做法 - 最安全並有效的獲取注入服務的方法:
1 | // UserService actually injected into the component |
第二種做法 - 透過 TestBed.get() 來使用根注入器獲取該服務:
1 | // UserService from the root injector |
不過這只有當 Angular 元件需要的恰好是該測試的根注入器時才能正常使用。
而加入這部分後,我們的程式碼目前是這個樣子的:
1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; |
接著補上一些測試例:
1 | it('should welcome the user', () => { |
el.textContent
有沒有包含「歡迎」、「Test User」同樣是元件的測試,這次試著假設一些不同的狀況,練習如何對這些元件進行測試。
建立一個 BannerComponent 並且透過繫結到元件的 title
屬性來展示動態標題。
輸入 ng g c banner -m app
產生 BannerComponent 並且於 app.module 內註冊。
class
1 | import { Component, OnInit } from '@angular/core'; |
Template
1 | <h1>{{title}}</h1> |
app.component
1 | <app-banner></app-banner> |
將會寫一系列測試來探查 h1 標籤的值。
首先在 beforeEach() 中使用標準的 HTML querySelector 來找到該元素。
1 | describe('BannerComponent', () => { |
測試看看!
createComponent() 函式不會繫結資料,因為繫結是在 Angular 執行變更檢測時才發生的。
TestBed.createComponent 不能觸發變更檢測,所以要補上 detectChanges() 。
透過呼叫 fixture.detectChanges() 來要求 TestBed 執行資料繫結。
1 | it('最終呈現在網頁上的標題文字是否與元件內 title 屬性的值相同', () => { |
這種變更檢測是故意設計的,它給了測試者一個機會:
像是我們可以這麼做:
1 | it('最終呈現在網頁上的標題文字是否與元件內 title 屬性的值相同', () => { |
在呼叫 fixture.detectChanges() 之前修改元件的 title 屬性。
BannerComponent 的這些測試需要頻繁呼叫 detectChanges() ,而 Angular 測試環境可以做到自動執行變更檢測,如:
1 | describe('BannerComponent', () => { |
第一個測試的例子展示了自動 detectChanges() 的好處。
第二、三個例子要說明的是,Angular 測試環境不會知道測試程式改變了元件的 title
屬性。
自動檢測只對非同步行為比如承諾的解析、計時器和 DOM 事件作出反應,直接修改元件屬性值是不會觸發自動檢測的。
測試程式必須手動呼叫 detectChange(),來觸發新一輪的變更檢測週期。
修改剛剛的範例,替這個元件增加一個 Input 輸入,根據輸入來變化標題。
banner Template
1 | <h1>{{title}}</h1> |
接著需要到 app.module 內 import FormsModule
才可以使用雙向繫結。
如果想在測試時模擬使用者輸入,你就要找到 input 元素並設定它的 value 屬性。
而 Angular 不知道我們設定了 input 元素的 value 屬性,所以需要先呼叫:
最後別忘了同樣也必須在 TestBed.configureTestingModule 內 import FormsModule
。
1 | describe('BannerComponent', () => { |
介紹完服務元件的測試後,接著要學習如何測試一個單純的元件。
在 Angular 中,普通的元件包含了 Template 與 class ,因此想對元件進行充分的測試,勢必得對這兩個部分都進行測試才行。
這些測試需要在瀏覽器的 DOM 中建立元件的宿主元素(就像 Angular 所做的那樣),然後檢查元件的 class 和 DOM 的互動是否如同 Template 中所描述的那樣。
Angular 的 TestBed 為所有這些型別的測試提供了基礎設施。
但是很多情況下,可以單獨測試元件類本身而不必涉及 DOM ,就已經可以用一種更加簡單、清晰的方式來驗證該元件的大多數行為了。
測試之前我們要先準備環境,因此建立一個元件 - lightSwitch
輸入
ng g c lightSwitch
建立元件。
Template
1 | <button (click)="clicked()">Click me!</button> |
class
1 | import { Component, OnInit } from '@angular/core'; |
app.component Template
1 | <app-light-swich></app-light-swich> |
可以像先前測試服務元件般,單獨測試元件中的 class 。
而這個元件的 class 並沒有依賴任何的服務元件,是非常單純的。
這種情況下可以直接 new 出物件實體,進行 isOn
屬性的狀態測試。
light-swich.component.spec
1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; |
完整的元件不只有 class ,元件還要和 DOM 以及其它元件進行互動。
只涉及 class 的測試可以得知元件 class 的行為是否正常,但不能得知元件是否能正常渲染出來、響應使用者的輸入和查詢或與它的父元件和子元件相整合。
要進行完整的測試,我們不得不建立那些與元件相關的 DOM 元素了,必須檢查 DOM 來確認元件的狀態能在恰當的時機正常顯示出來,並且必須透過螢幕來模擬使用者的互動,以判斷這些互動是否如我們預期。
而這部分就需要用到 TestBed 了。
當我們建立元件時, Angular CLI 會幫我們寫好預設的測試
1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; |
而有些時候並不需要這些 Angular CLI 幫我們寫好的 Code 。
Angular CLI 是預設我們可能會用到這些東西,而目前我們可以再精簡一些。
1 | describe('LightSwichComponent (minimal)', () => { |
當元件逐漸演變成更加實質性的東西時,才會用到那些 Angular CLI 幫我們預設產生的測試。
在這個例子中,傳給 TestBed.configureTestingModule 的元資料物件中只宣告了 LightSwichComponent - 也就是待測試的元件。
不用宣告或匯入任何其它的東西,預設的測試模組中已經預先配置好了,
比如來自 @angular/platform-browser 的 BrowserModule。
在配置好 TestBed.configureTestingModule() 之後,可以呼叫它的 createComponent() 方法。
TestBed.createComponent() 會建立一個 LightSwichComponent 的元件,把相應的元素新增到 test-runner 的 DOM 中,然後返回一個 ComponentFixture 物件。
特別要注意的是,在呼叫了 createComponent 之後就不能再重新配置 TestBed 了。 createComponent 方法凍結了當前的 TestBed 定義,關閉它才能再進行後續配置。
也就是說不能:
如果試圖這麼做,TestBed 就會丟擲錯誤。
ComponentFixture 是用來與所建立的元件及其 DOM 元素進行互動。
從剛才的範例程式碼來看,我們可以透過 fixture
來訪問該元件的 instance ,並用 Jasmine 的 expect 語句來確保其存在。
隨著元件的成長,可能會有很多組測試。
這時除了一直複製之外,還有個比較好的做法:
因此可以重新調整剛剛那段程式碼:
1 | describe('LightSwichComponent (minimal)', () => { |
可以使用 nativeElement 中獲取元件內的元素,像是:
1 | describe('LightSwichComponent (minimal)', () => { |
console.log(el);
印出來的內容是:
於是可以藉由 .getElementsByTagName() 找到 button 標籤,最後再判斷長度,即可測試這個元件內有沒有按鈕了。
而除了使用 nativeElement 取得元件內的元素之外,也可以透過 DebugElement 取得元件內的元素。
至於為什麼要使用 DebugElement 呢?
以下是官方文件給出的解釋:
nativeElement 的屬性取決於執行環境。 你可以在沒有 DOM,或者其 DOM 模擬器無法支援全部 HTMLElement API 的平臺上執行這些測試。Angular 依賴於 DebugElement 這個抽象層,就可以安全的橫跨其支援的所有平臺。 Angular 不再建立 HTML 元素樹,而是建立 DebugElement 樹,其中包裹著相應執行平臺上的原生元素。 nativeElement 屬性會解開 DebugElement,並返回平臺相關的元素物件。
所以上面那個使用 nativeElement 的測試可以改寫成:
1 | describe('LightSwichComponent (minimal)', () => { |
改寫後的結果會與改寫前完全一樣。
實務上的元件可能不會像範例上一樣這麼單純,可能會依賴某個服務元件之類的…情況,因為再寫下去可能篇幅會過長,所以決定先在這邊設個中斷點。
]]>測試對於一個公司的專案有多重要自然不言而喻,如何在 Angular 中進行測試呢?讓我們一起學習吧。
相信測試的好處以及壞處網路上隨便搜尋能夠找到相當大量的文章,所以這部份我想應該也不過多著墨在這裡了,以下附上一些文章,目的在於建立對於那些陌生名詞的認知:
根據官方的文件描述 Angular CLI 會下載並安裝 Jasmine 測試框架,測試 Angular 應用時所需的一切。
而每個新開的 Angular 專案都可以直接運行 ng test
指令立即進行測試。
接著運行 ng test
指令,會看到一些測試的過程
而測試執行完畢後,也會開啟 chrome 瀏覽器並在 Jasmine HTML 報告器中顯示測試結果。
可以直接點選 AppComponent 連結,重新跑過 AppComponent 底下的測試,或者是點擊掛在 AppComponent 下的連結也可以進行單獨的測試。
而這個由測試指令開啟的 chrome 瀏覽器會持續監聽程式碼的變化,因此可以對 app.component.ts 做一個小修改,並儲存它。
這些測試就會重新執行,瀏覽器也會重新整理,然後新的測試結果就出現了。
而直接關閉由測試指令開啟的 chrome 瀏覽器會馬上又被開啟,原因是必須回到終端機按下 CTRL + C 終止指令運行才可以正確關閉。
通常的情況下 Angular CLI 會自動的產生 Jasmine 和 Karma 的配置檔案。
也可以透過編輯 src/ 目錄下的 karma.conf.js 和 test.ts 檔案來微調很多選項。
Angular CLI 會基於 angular.json 檔案中指定的專案結構和 karma.conf.js 檔案,來在記憶體中構建出完整的執行時配置。
而如果要調整這些預設的設定,可能就要到 Jasmine 和 Karma 的官方文件找找了。
在 Angular CLI 中, ng test
指令後面可以額外追加一些參數產生程式碼覆蓋率報告。
根據 WIKI 的解釋,程式碼覆蓋(英語:Code coverage)是軟體測試中的一種度量,描述程式中原始碼被測試的比例和程度,所得比例稱為程式碼覆蓋率。
白話來說大概可以當成是專案中的健康指標之類的吧,越高越好的那種。
附上一篇雖然年代久遠,但對於名詞的了解上仍有一定的幫助:
指令 ng test
後面可以接的參數可參考
執行下列指令,觀察檔案結構有何異動:
1 | ng test --no-watch --code-coverage |
當測試完成時,該命令會在專案中建立一個新的 /coverage 目錄。
這裡我使用 VS Code 的插件 - Live Server 開啟其 index.html 檔案以檢視帶有原始碼和程式碼覆蓋率值的報告。
除了每次在 ng test
指令後方加入參數的作法外,也可以透過 Angular CLI 的 angular.json
中設定:
1 | "test": { |
刪除 coverage 資料夾,執行
ng test
指令觀察是否仍產出 coverage 資料夾。
服務元件的測試算是比較單純的,因為服務元件建立後,不像普通的元件含有 Template 的部分。
使用 ng g s demo
指令建立服務元件,並且在 demo.service 寫一些程式。
demo.service.ts
1 | import { Injectable } from '@angular/core'; |
接著在 demo.service.spec 寫測試案例。
1 | import { TestBed } from '@angular/core/testing'; |
Angular CLI 預設會幫我們把服務元件 import 進該服務元件的測試檔中,方便進行測試。
第一個測試利用了 new 讓 DemoService 實體化,並且得以取用 DemoService 內的方法:
而這段測試 Code 使用的方法可以從 jasmine 的文件中找到詳細描述。
describe(description, specDefinitions)
it(description, testFunction, timeout)
expect(actual) → {matchers}
toContain(expected)
expect(languages).toContain('en');
為 languages 陣列是否有包含 en
字串在某個陣列元素內toBe(expected)
輸入測試指令
ng test
並觀察。
而刻意營造錯誤的話則會呈現:
在實務中,服務元件最終會透過 Angular 的相依注入 (DI) 來建立服務。
而 TestBed 會動態建立一個用來模擬 @NgModule 的 Angular 測試模組,因此我們可以將服務元件注入到 TestBed ,接著就可以進行測試了。
1 | import { TestBed } from '@angular/core/testing'; |
providers
屬性中指定一個將要進行測試的陣列。上面那一段測試 Code 還有可以優化的地方,例如:
service = TestBed.get(DemoService);
提出來放在 beforeEach() 內1 | import { TestBed } from '@angular/core/testing'; |
新增另一個服務元件 demo2 並且把 demo 注入,使 demo2 形成對 demo 的相依關係。
demo1.service
1 | import { Injectable } from '@angular/core'; |
demo2.service
1 | import { Injectable } from '@angular/core'; |
撰寫 demo2.service.spec.ts
1 | import { TestBed } from '@angular/core/testing'; |
使用 jasmine.createSpyObj() 產生一個含有 getArray
方法的物件。
而 useValue
可以參考官方說明文件,定義物件使用 useValue
作為 key 來把該變數關聯起來。
最後需要透過 .getArray.and.returnValue()
設定
如果少了這個步驟,呼叫 getArray 方法的話可是什麼事情都不會發生的。
透過這樣的方式使 demo2 與 demo 脫鉤,就可以單獨測試 demo2 。
在學習這一部分的時候,遇到蠻明顯的卡關…。
主要是因為看不懂官方中文的文件,感覺省略了很多東西。
例如對服務的測試 Service Tests ,這邊範例感覺提供的不夠完整,讓我不知從何下手做起。
於是我轉而參考其他篇文章,如:
並與官方的程式碼交叉參考,反覆撞牆下才完成本次的範例實作。
]]>延續上一篇的情境,現在有了前台與後台的區分。但實務上後台是不允許未經認證的人隨意進入瀏覽的,通常會搭配一個登入介面,而光靠登入介面還不夠,因為使用者很可能會藉由直接輸入網址的方式瀏覽後台頁面,這時候就需要依賴路由守衛 (Route Guards) 了 。
延續上一份 ngRouteDemo 的範例,現在需求為:
範例參考中文 Angular 官方手冊
根據官方文件說明,路由守衛被應用的場合可能會是:
可以藉由路由配置中新增守衛,來處理以上這些應用場合。
路由守衛會返回一個值,以控制路由器的行為:
true
導航過程會繼續false
導航過程就會終止,且使用者留在原地false
時,也可以告訴路由器導航到別處,例如可以導航到登入頁面路由器可以支援多種守衛介面:
路由守衛檢查的順序
根據手冊上的描述,在分層路由的每個級別上,可以設定多個守衛。
如果任何一個守衛返回 false
,其它尚未完成的守衛會被取消,這樣整個導航就被取消了。
這樣看起來最適合這個範例使用的應該就是 CanActivate 了。
所以我們要使用 CanActivate 來保護後台的所有頁面不被未授權的使用者看到。
但是在這個範例中,為了方便示範:
false
輸入指令,以下兩種指令則一即可
1 | ng generate guard auth/auth |
1 | ng g g auth/auth |
輸入後 Angular CLI 也會很貼心的問我們要使用哪一種
選擇 CanActivate 後按下 Enter ,發現 Angular CLI 建立了兩隻檔案,分別為測試檔以及主要檔案。
這支由 Angular CLI 建立的檔案,剛打開時什麼都沒有,而且還會跳出錯誤。
1 | import { Injectable } from '@angular/core'; |
顯然的需要在這裡寫一些東西才行。
於是可以先使用官方提供的範例,觀察一下運作情形,稍後再來修改成我們要的。
@angular/router
中 import CanActivate
auth.guard
1 | import { Injectable } from '@angular/core'; |
這樣基本的路由守衛配置就完成了。
接著把剛才設定好的路由守衛匯入 back-routing.module 。
1 | import { NgModule } from '@angular/core'; |
搞定,先運行看看吧!
成功了,接著我們回過頭來研究 auth.guard.ts 檔內的 canActivate() 吧!
可以參考官方提供的文件
1 | import { Injectable } from '@angular/core'; |
為了使未認證的使用者能導航到指定頁面,我們需要匯入 Router
類別,並且在建構式內實例化,接著使用底下的方法 navigate()
。
強制返回 false
,並且觀察當觸發路由守衛時,是否會正確的導航到指定頁面。
搞定了!測試看看吧。
嘗試輸入後台子頁面的網址仍被路由守衛攔下,由於畫面相同就不重複張貼了。
於是靠著官方手冊的指引,我們成功地建立了一個路由守衛,使得未通過驗證的人不得訪問後台的網頁。
當然這部分省略了很多東西,但我目的僅為了實作一個簡單的路由守衛範例,並驗證它是真的有效,所以就省略了登入步驟,直接設定不管如何都是 false
,藉以觀察當未通過驗證時是否正確導航到指定目的地。
還記得那個下定決心的夜晚是 2018 年的 6 月 11 日,那個時候的我仍任職於鄉下某間負責修理電腦、偶爾打雜,職稱卻掛 MIS 的公司。距今也已經差不多快要一年了,換句話說我花了一年的時間努力,才終於跨過轉職的門檻,得以成為 Junior 前端工程師。
我本身是私立科大資工系畢業的,那個時候系上還在推寫 APP ,不過那個時候很混,算是把大學的時間都玩掉了,畢業的時候連點像樣的東西都沒有。
接著就去當兵了,退伍後更是什麼程式語言、演算法都不記得了。
幸好本科系的加持還是有差的,雖然大學期間頗混,但在耳濡目染下一些基礎的計算機概論常識還是有的。
後來應徵上了一間公司,雖然職稱是掛 MIS ,不過就如同前言所提到的:
雖然寫程式的比例頗低,但我第一件進公司的事情是:
其實我不會這些東西,當時面試時就只是說了「我願意試試看」就錄取了。
後來事實證明我辦到了,雖然從現在的角度看來那網頁有點慘不忍睹,不過至少階段性任務還是達成了。
而我在接觸網頁的過程中逐漸的喜歡上這部分,對我來說網頁最大的魅力莫過於:
由於任職的公司對於前端網頁的技術能量需求不大,而我任職快滿 2 年時逐漸萌生轉職的念頭,覺得自己這樣下去不行。
對我來說這樣的職涯,學習已經停滯了,而且這份工作技術含量太低了。
我也不敢想像三、四十歲時仍然蹲在桌子下幫人安裝電腦、修理電腦的那個畫面。
話是這麼說沒錯,但辭掉這份工作後,我會什麼?
我說不上來。
基於先前替公司寫網頁時的感覺,有了一個不太肯定的答案「或許我可以寫看看網頁?」
現在看來也不是什麼破釜沉舟的決定,就只是相較之下比較合理的選擇。
既然都決定要轉職成一個以「寫網頁」當作工作的人,那具體來說該怎麼辦呢?
那時候的我連前端這個名詞都不知道,我只知道我想把寫網頁當成工作,就這麼簡單。
但我一點方向都沒有,那個時候看著自己替公司做的網頁醜到不行,朋友說可以試試看套 Bootstrap 4 ,至少不會這麼醜。
我按照朋友的建議,看了一下 Bootstrap 4 該怎麼用,但是怎麼用就是不順。
後來因為一直看到網頁內的某個廣告,好奇驅使下就點擊了,這也是認識六角學院的開始。
因為六角學院的資源下,我知道了前端工程師大致上是在做什麼的、也有了一些方向。
更不可思議的是也間接地拓展了一些人脈。
拓展人脈這點也是始料未及的,起因只是因為被建議可以寫寫筆記、記錄學習心得,讓自己學得更好。
那個時候我選擇 Medium 並且寫了 「JavaScript 的奇怪部分」筆記,並且被建議可以試著貼到社團,這樣可以幫助同樣在學習這個部分的新手。
而這個建議奠定了到現在我還仍然喜歡寫文章的基石,也認識了一些同樣愛分享的前端朋友。
因為這些人脈,對於前端的知識在這些日子裡有飛躍式的提升,亦獲得一些實質的建議與資源,諸如:
而更重要的是,也是因為寫了這些文章,儘管我不會 Angular ,但我的履歷仍被現在的主管看中。
後來與現任主管閒聊時,他說「寫技術筆記這件事,可以當成是一種自學能力的展現,雖然你不會 Angular ,但我認為可以讓你試試看。」
所以直到現在,我仍然維持著寫作的習慣,相信這可以讓人變得更好,也可以讓更多比我菜的新手得到幫助。
雖然現在連試用期都還沒過,但我總算是完成了當初自己設下的里程碑,這對肯定自己過去投入的那些時間學習是很有幫助的。
而現在的公司也蠻符合內心的期望:
剛進公司的第一個禮拜就參與了自家產品的優化會議
總之剛進公司的第一個禮拜什麼事情都蠻新鮮的,除了很多陌生的名詞要記憶以及熟悉專案結構外,主管也決定要先在我們 UI Team 內試著跑跑看 Scrum 敏捷開發,並且試著利用這種開發模式做一個 Side Project ,當成是讓我練習 Angular 以及與團隊其他成員的協作。
]]>對我來說一切才剛要開始,這些都是還我沒有經歷過的,目前一切是這麼的令人期待,我也不能浪費這些機會,得好好把握才行。
在上一篇的實作中,我們完成了一個最基礎的 Angular Router ,而本文將修改需求,將其擴充並實作出一個具有子路由功能的範例。
延續上一份 ngRouteDemo 的範例,現在需求變更為:
先從區分前後台開始做起吧!
新增兩個功能模組分別為:
新增功能模組,如:
1 | ng g m front |
執行完指令後 Angular CLI 會自動將我們新增的功能模組 import 至 AppModule 內。
此時 app.module
內容如:
1 | import { BrowserModule } from '@angular/platform-browser'; |
特別注意此處
imports
陣列內的順序,父路由最後只會負責萬用字元路由與預設路由,因此應將其順序放於最後。
我們新加入了兩個功能模組用來區分前後台,接著要建立前後台都有的 index 元件,目的是作為殼使用。
cd 進剛才建立的功能模組內,並新增元件,如:
1 | ng g c index |
最後將之前建立好的 Page1 、 Page2 、 Page3 元件搬進 front / back 功能模組內。
最後結構上會像是這樣
別忘了要在建好的功能模組內, import 相關的元件進來,並且設置要
exports
的元件,因為最後配置父路由時會需要用到。
front.module
1 | import { NgModule } from '@angular/core'; |
back.module 同 front.module 設置。
front 功能模組 IndexComponent 的 Template
1 | <h2>現在位於 front 元件,請點擊以下連結切換頁面</h2> |
back 功能模組 IndexComponent 的 Template
1 | <h2>現在位於 back 元件,請點擊以下連結切換頁面</h2> |
AppComponent 的 Template
1 | <h1>點擊以下連結切換元件</h1> |
複製 app-routing.module
並且貼進 front 功能模組內,重新命名為 front-routing.module
。
修改 front.module
,將剛才新增的路由設定檔引入,如:
1 | import { NgModule } from '@angular/core'; |
修改 front-routing.module
1 | import { NgModule } from '@angular/core'; |
這部分 back 功能模組同 front 功能模組配置。
配置完功能模組的子路由後,最後只需要微調一下這邊就可以了。
1 | import { NgModule } from '@angular/core'; |
這裡 import front 功能模組內的 IndexComponent 元件,因為我希望當使用者胡亂輸入時會顯示這個元件。
本來以為這部分應該不會太複雜,但在不熟 Angular 的情況下我還是搞了蠻久的。
當初預計這一篇要一起實作路由守衛的部分,看來只能下一篇了,不然篇幅太長囉。
]]>今天是進入公司的第三天,為了能盡快投入專案與成為團隊可用的戰力,我正在努力啃官方文件學習 Angular 的知識,所以這一篇文章主要是記錄我如何閱讀官方文件後,實作這個非常基本的、帶導航的網頁應用。
需求大概是這樣的:
參考文件:
輸入 ng new ngRouterDemo
建立新專案,並直接選擇要使用 Router 。
這次選擇加入 Router 後,發現 app 資料夾內多了 app-routing.module.ts
1 | import { NgModule } from '@angular/core'; |
然後 app.module.ts
中也把 app-routing.module.ts
這隻檔案給引入了。
1 | import { BrowserModule } from '@angular/platform-browser'; |
對照一下有無加入 Router 的部分
除此之外就是 app 資料夾內多了 app-routing.module.ts
這隻檔案。
運行結果
沒有什麼明顯的變化。
輸入指令建立本次範例用的元件
如 ng g c page1
因為是選擇使用 Router 的模式,所以 Angular CLI 預設幫我們加入了 router-outlet
標籤,這代表路由切換後的畫面都會在這個標籤裡面呈現。
以下引用自官方說明:
RouterOutlet 是一個來自路由模組中的指令,它的用法類似於元件。 它扮演一個佔位符的角色,用於在範本中標出一個位置,路由器將會把要顯示在這個出口處的元件顯示在這裡。
有了這份配置,當本應用在瀏覽器中的 URL 變為 /heroes 時,路由器就會匹配到 path 為 heroes 的 Route,並在宿主檢視中的 RouterOutlet 之後顯示 HeroListComponent 元件。
因為要點擊連結後透過路由配置切換到該元件,所以必須先設置路由器連結 (Router links):
對 Appcomponent 進行 Template 上的調整
1 | <h1>點擊以下連結切換元件</h1> |
RouterLinkActive
的屬性繫結,像是 routerLinkActive="..."
為了方便辨識效果,所以也加入 .active 的樣式吧
1 | .active{ |
這邊要注意的是 CSS 樣式要寫在 Appcomponent 內。
要使用路由的話,必須要把要使用的元件 import 進來,並且在 routes
陣列內配置它們,陣列內傳入一個物件,而物件內可以傳入參數:
1 | import { NgModule } from '@angular/core'; |
當我們定義好路由陣列 routes 並把它傳給 RouterModule.forRoot() 方法後:
一旦啟動了應用 Router 就會根據當前的瀏覽器 URL 進行首次導航。
存檔,試著運行看看。
這樣基礎的 Angular 路由範例就完成了!
而因為我們有在 RouterModule.forRoot() 把 enableTracing
打開,所以當切換路由時時可以看到一些額外訊息:
雖然我們基礎的範例完成了但還不夠好,因為:
新增一個萬用字元路由來攔截所有無效的 URL 並處理它們。 萬用字元路由的 path 是兩個星號(**),它會匹配任何 URL。 當路由器匹配不上以前定義的那些路由時,它就會選擇這個路由。
萬用字元路由可以導航到自訂的 “404 Not Found” 元件,也可以重定向到一個現有路由。
特別要注意的是:
路由器使用先匹配者優先的策略來選擇路由,萬用字元路由是路由配置中最沒有特定性的那個,因此務必確保它是配置中的最後一個路由。
因此修改 app-routing.module
1 | import { NgModule } from '@angular/core'; |
像這樣,永遠確保萬用字元在最後一組路由就可以了,而且也不會跳錯誤。
與設置萬用字元時差不多,由於路由是有順序性的,因此應該其放在萬用字元路由的前一個。
1 | import { NgModule } from '@angular/core'; |
為了方便辨識,我將預設路由設置為 page2
,也就是預期當使用者初次進入網站時會看到這個畫面。
重定向路由需要一個 pathMatch 屬性,來告訴路由器如何用 URL 去匹配路由的路徑,否則路由器就會報錯。
在本範例中路由器應該只有在完整的 URL 等於 ‘’ 時才選擇 Page2Component 元件,因此要把
pathMatch
設定為 ‘full’。
可以觀察下方的 log ,發現一開始如果網址都沒輸入時,會自動跳轉到
page2
前面提到路由器使用先匹配者優先的策略來選擇路由,所以順序很重要,如果把萬用字元的順序稍微挪動,如:
1 | const routes: Routes = [ |
因為匹配到的路由會變成萬用字元的路由,因此就不會跳轉到 page2
了。
透過實作這個非常基本的、帶導航的網頁應用學到了如何:
當應用在空路徑下啟動時,導航到預設路由
]]>之後會嘗試實作路由守衛的部分。
在公司的專案中若有部分是屬於較早之前的專案,很可能在使用 npm install
或 yarn
指令時跳出因版本過高而無法順利下載專案相依套件,此時就只能考慮將目前的 node 版本降下來,或者透過 nvm 切換 node 版本了。
取得 nvm 的方式有很多種:
因為我的筆電環境沒有 curl 也沒有 wget ,索性就直接吃安裝檔了,最方便。
關於詳細安裝方式可以看官方的 GitHub
之後可以打開終端機輸入 nvm version
查看目前版本,有看到版本代表安裝成功。
輸入以下指令取得 node 其他版本:
1 | nvm install <version> |
如 nvm install 9.0.0
,如此一來就會安裝 node 9.0.0 的版本
查看目前可切換的版本
1 | nvm ls |
切換 node 版本
1 | nvm use <version> |
如 nvm use 9.0.0
,切換到 node 9.0.0 的版本
使用別名稱呼版本號
1 | nvm alias <Name> <version> |
如 nvm alias forWork 9.0.0
,這樣就完成別名的命名,爾後就能使用 nvm use forWork
切換到 node 9.0.0 版了。
因為我的電腦工作時已經安裝一次,這部分就偷懶不補圖片了。
今天是到職的第一天,在完成報到手續以及認識環境後,隨即就正式開始對公司專案的探索了,持續的督促自己,也謝謝今天給予我指點的前輩。
]]>不知不覺也到了 No.37 了,最後還想要再介紹一個名為 Async 的管道元件,說到管道元件一定不陌生,因為先前就介紹蠻多常用的管道元件了。
管道元件可以讓我們用在 Template 上的屬性繫結或者內嵌繫結的地方。
使用方法也相當簡單,加上 | 符號後再加上要使用的 Pipe 元件即可。
Async 管道元件 也可以在官網找到相關文件
Async 管道元件就是用來訂閱任何一個 Observable 物件 (觀察者物件)
因此,讓我們再度進行重構吧!
atticleData
重新命名為 atticleData$
<any>
atticleData
,所以有用到屬性 atticleData
的方法全部都報錯1 | import { Component, OnInit } from '@angular/core'; |
而 Template 部分一樣要修改,豪邁的砍掉錯誤的部分
1 | <!-- Article START--> |
最後來看一下結果吧:
透過這個簡單的範例介紹 Async 管道元件的用途。
而通常 Async 管道元件會被用於只需要單純取得資料且來源又是 Observable 時可以簡化不少程式碼。
總算是跑完這一系列了,也逐漸覺得 Angular 有不少好用的地方是 Vue 所體會不到的,但有部分的觀念其實很類似,所以學習起來不會感到特別吃力。
但是儘管我完成了這一系列,不可置否的是我仍然對於 Angular 語法以及 TypeScript 的語法不夠熟悉,這樣是沒辦法熟練應用於實務上的,因此還必須持續的努力、複習。
這一系列完成於到新公司上班的前一天,而我也是因為這間公司才開始投入學習 Angular 的,希望透過這樣職前的自我訓練,能在進入公司後盡快地掌握公司內 Angular 的專案。
一系列的文章通常只有最初跟最後有人看,所以我還是在這裡把環境稍微交代一下:
用得好好的為啥要重構呢?原因是目前這個 DataService 並沒有寫得很漂亮,原因是通常不會直接在服務元件內直接做 .subscribe() 的動作,大部分的情況都是在其他元件內進行。畢竟什麼時候要訂閱是各個元件自己才知道,服務元件單純的提供服務就好。
移除先前為了介紹寫在建構式內的程式碼,並且新增一個方法叫 getData()
1 | import { Injectable } from '@angular/core'; |
因為先前的調整,ArticleList 內幾乎已經沒有程式碼了,原因是該元件的 Tamplate 內是直接讀取 DataService 的資料。
但現在 DataService 已經沒有 atticleData
屬性了,怎麼辦呢?
解決辦法:
atticleData
屬性atticleData
屬性移除了,現在必須加回去1 | import { Component, OnInit } from '@angular/core'; |
這樣看起來就合理多了,應該是由元件來決定何時訂閱,而不是由服務元件來決定。
因為可能會有多種不同的事件來處發 API ,所以由個別元件來決定是最合理的。
調整完後還有 Template 要修改:
1 | <!-- Article START--> |
最終 ArticleList 是這樣的:
1 | import { Component, OnInit } from '@angular/core'; |
重新整理一下想法:
也就是說需要回到 DataService 上調整 doDelete() 、 doModify() 這兩個方法。
doDelete()
doModify()
post
, httpClient 會自動把 post
這個物件轉換成 json 格式1 | import { Injectable } from '@angular/core'; |
至此 DataService 就調整完畢了,最後再次回到 ArticleList 元件完成真正的刪除與修改!
用法是這樣的:
item
並且訂閱結果同理 doModify() 也是。
另外,串接 API 時常常會遇到一些意外的狀況,此時
1 | import { Component, OnInit } from '@angular/core'; |
大功告成,來試試看吧!
一切都符合我們的預期 :D
之前介紹服務元件的時候就已經看過 @Injectable() 但那個時候並沒有詳加著墨介紹,究竟 @Injectable() 是什麼意思呢?
Injectable 本身是一個裝飾器 (Decorator) ,主要目的是用於描述這個類別 (class) 是否可以被注入其他的服務元件,事實上如果把之前服務元件上寫的 @Injectable() 刪除,還是可以成功地注入其他元件上,舉例來說:
1 | import { Injectable } from '@angular/core'; |
刪除 @Injectable()
後功能依然正常,也就是說移除裝飾器後,服務元件依然有被注入成功。
因為它可以注入一些額外其它的服務元件,什麼意思呢?
我們可以透過 @Injectable() 注入 HTTP Client 的元件,從伺服器取得動態的資料。
在 Angular 中有內建一個服務元件 HTTP Client ,所以先將其注入目前的 Data Service 中。
import { HttpClient } from '@angular/common/http';
這才是正確的 HTTP 模組來源
1 | import { Injectable } from '@angular/core'; |
處理完之後,這個 httpClient 注入還是失敗的,因為還沒將它加入到 ArticleModule 下。
錯誤訊息說明了必須匯入 HttpClientModule 到 ArticleModule
此時網頁又可以正常運作了
前面有提到如果把 @Injectable()
刪除不影響程式運作,但此時如果直接將 @Injectable()
移除可是會出錯的。
意思是無法解析 DataService 內所有的參數,雖然不太懂是什麼意思,但如果看到 resolve 通常是跟相依注入有關。
因為此時相依注入是從建構式內找到一個 httpClient 參數,然後參數標註 HttpClient 型別
當透過 Angular CLI 建立服務元件時,預設都會自動加入 @Injectable()
,建議還是不要把它胡亂移除比較好。
於是 HttpClient 服務元件就注入完成了!
目前文章資料都是寫死的,可以透過 HttpClient 服務元件當中的 get() 方法,動態的取得伺服器上的文章資料。
詳細的 http.get() 方法可以參考官網
而這邊要注意的是 Ajax 的操作在瀏覽器內是非同步的,因此沒辦法在建構式內直接回傳取得的結果
1 | import { Injectable } from '@angular/core'; |
透過 HttpClient 服務元件,正確的接收到資料了!
]]>呈上篇,在掌握如何使用服務元件並且透過 DI 將其注入元件中使用後,緊接著我們就可以利用此技巧將原本元件內的邏輯抽出進行重構了。
在這個例子中 Data Service 只會用在 ArticleModule ,所以最好是把 data.service 的兩隻檔案都搬進去模組資料夾內,比較方便管理。
但是現在的情況下並不能直接移動,因為這個服務很可能在不同地方被引用了,一旦移動位置就必須跟著改其他地方。
做法有二:
現在這個情況就使用 Move TS 來處理吧!
如果已經裝好了這個插件,那麼在移動 TS 檔案的時候,這個插件就會自動地幫我們把所有參考到這個檔案的 TypeScript 檔一併修改路徑,讓我們測試看看。
接著開始搬運 ArticleList 的部分程式碼包含資料與邏輯的部分,都搬進 Data Service 。
articleData
1 | import { Injectable } from '@angular/core'; |
這樣 Data Service 的部分算是處理好了,接下來回到 ArticleList 。
datasvc
這個屬性裡面,因此可以從這取得原本文章的資料維持畫面的呈現。1 | import { Component, OnInit } from '@angular/core'; |
程式碼都搬運了,自然 Template 的部分也需要做調整:
datasvc
屬性即可取用服務元件下的方法private
是無法在 Template 內取用的,要改成 public
public
修正後即可順利取用方法
1 | <!-- Article START--> |
迫不及待地馬上測試看看!
然後會發現完全沒有效果。
為什麼?明明 console.log 也沒有錯誤訊息?
問題在於,在 JavaScript 中有個非常重要的語言特性是:
什麼意思呢? 先來看看服務元件內 doModify() 的寫法
1 | doModify(post: any){ |
意思是 this.atticleData
透過 map() 被賦值一個全新的物件
atticleData
值都是一個全新的物件但是第一次進行相依注入時, ArticleList 元件內的 ngOnInit() 我們是這麼寫的:
1 | ngOnInit() { |
就是把
this.datasvc.atticleData
所指向的物件參考傳給了atticleData
這個屬性
所以當服務元件 DataService 內重新又建立一個元件時,這裡的 ngOnInit() 可不會重新又執行一次,也就是說我們在 DataService 上做的任何修改 ArticleList 完全看不到。
既然方法可以直接從服務元件內取用,那麼資料想必也是可以的。
所以我們修改 ArticleList 的 Template
1 | <!-- Article START--> |
因為改用 datasvc.atticleData
所以可以刪除 class 內不再用到的 atticleData
屬性
this.atticleData = this.datasvc.atticleData;
1 | import { Component, OnInit } from '@angular/core'; |
於是現在這個 ArticleList 元件的 class 基本上快被搬光了,只剩下一個服務元件 Data Service 的注入,所有的邏輯都在服務元件上,而 Template 是直接取用服務元件上的資料以及邏輯。
再次進行測試
大功告成!
實作完成後發現服務元件蠻吃 JavaScript 觀念,如果基礎沒有打穩,當發生這個 Bug 時肯定找不到問題,更別說這個問題連開發者工具都沒顯示錯誤,若基礎不穩肯定不知道發生什麼事了。
]]>到目前為止大部分的程式碼都放在 ArticleModule ,而這個 Module 內包含三個元件,其中 ArticleList 是父元件 ; ArticleHeader 與 ArticleBody 為子元件。大部分的程式邏輯與資料全部都放在 ArticleList 內,那麼我們要如何利用服務元件來協助處理這部分呢?
服務元件是一個類別,而類別內只有兩種東西
既然類別內只有這兩種東西,此時就可以想著該把什麼東西給獨立抽離使其變成服務元件。
以 ArticleList 元件為例
在設計元件的時候,通常會把相關的資料或邏輯放在同一的元件做管理。
而這個元件的 OnInit() 內放有文章的初始化資料內容,因此:
建立的方式同樣透過 Angular CLI 指令,以下則一即可:
ng generate service 名稱
ng g s data 名稱
此時會發現專案內多了兩支檔案,單元測試檔及服務元件主要的程式碼。
data.service.ts
1 | import { Injectable } from '@angular/core'; |
這樣的程式碼結構是不是跟之前建立元件時看到的很相似呢?
建立好 Data Service 後,接著還需要將其註冊進模組 (Module) 內才可以使用。
目前有兩個模組,分別是 AppModule 以及 ArticleModule
如果要將服務元件註冊進模組內,必須:
providers: [DataService]
article.module
1 | import { NgModule } from '@angular/core'; |
這樣子服務元件就註冊完成了!
而任何一種元件內都可以透過相依注入 (DI) ,把服務元件取出來使用。
目前 Data Service 的確沒有任何程式碼在裡面,但我們可以做個小測試驗證一下。
1 | import { Injectable } from '@angular/core'; |
接著使用相依注入,注入到 ArticleList 內。
如何進行相依注入:
dService
屬性,並且利用 TypeScript 宣告型別為 DataServicedatasvc
,並且利用 TypeScript 宣告型別為 DataServicedService
屬性值給定 datasvc
參數1 | import { Component, OnInit } from '@angular/core'; |
第一次做相依注入的同時 Angular 會自動的 new
出 DataService 的 class
,所以參數 datasvc
得到的其實是一個物件的實體。
而不管有幾個 Component 要注入相同的元件,在預設的情況下 Angular 只會
new
一次,也就是最終只會得到一個唯一的物件實體。
如此一來不管是在任何的 Component 內,只要是相同的服務元件,就可以確保得到的物件是唯一的,因此可以更輕易的在不同元件間共享一些必要的資料。
透過 TypeScript 有效的簡化使用相依注入時的語法
public
或 private
修改如下:
1 | import { Component, OnInit } from '@angular/core'; |
修改後一行就搞定了~而且完全等價於剛剛三行的寫法。
所以未來再進行其他的相依注入時,只是要適當地在建構式參數前面加上 public
或 private
,後面再透過 TypeScript 的型別標註,就可以快速完成 DI 。
以 ngOnInit() 為例:
1 | ngOnInit() { |
最後,看看結果是否如我們預期吧!
本篇介紹了如何建立服務元件以及注入到其他元件內,下一篇我們要使用這個技巧替 ArticleList 進行重構。
]]>之前曾經介紹到 Angular 元件生命週期的 Hook分別是 ngOnInit 與 ngOnDestroy ,在那篇文章內曾經說過元件被實體化的過程,第一個先執行的是建構式 constructor ,也提到盡量不要再建構式裡面寫程式碼。這次要介紹的式另一個生命週期的 Hook - ngOnChanges 。
在介紹之前,來一張元件生命週期的圖表:
ngOnChanges() 與 constructor() 、 ngOnInit() 之間的執行順序是如何,可以很容易地從這張圖表上看出來。
但秉持著實踐精神,我們還是實際寫一些程式測試看看~
1 | import { Component, OnInit, Input, OnChanges } from '@angular/core'; |
因為有跑 ngFor 而文章資料總共有 6 筆,因此產生 6 個 ArticleBody 元件。
這就是它們的執行順序。
ngOnChanges() 觸發的時機點
counter
counter
值會從父元件透過屬性繫結傳入counter
發生改變時 ngOnChanges() 就會觸發,由此驗證ArticleBody
1 | export class ArticleBodyComponent implements OnInit, OnChanges { |
1 | <article class="post" id="post{{idx}}" *ngFor="let item of atticleData; let idx = index"> |
ArticleList
1 | export class ArticleListComponent implements OnInit { |
為了方便所以補上 item.id
,接著運行開發伺服器,觀察 console.log
變化:
由此可知,在兩秒後六個不同的 ArticleBody 元件都觸發了 ngOnChanges() ,證明了只有在屬性繫結傳入資料發生改變時才會觸發 ngOnChanges() 。
另外 ngOnChanges() 可以傳入一個參數,比較傳入前與傳入後的資料:
1 | ngOnChanges(changes) { |
發現這個 changes
接收到一個叫物件,點開後會發現裡面還有一層以 counter
屬性命名的物件:
currentValue
previousValue
firstChange
是不是第一次發生改變false
透過這個例子得知可以利用這個參數在觸發 ngOnChanges() 時多做一些額外的判斷。
前面幾篇文章提到,建議不要在子元件上直接使用雙向繫結修改傳入的資料,因為這樣會直接的修改到原始資料。
但現在我們懂得使用 ngOnChanges() 這個 Hook ,因此可以針對這個部分對整個程式碼進行重構。
切回 ActicleHeader 元件觀察:
@input()
綁定了 item
屬性item
資料時 ngOnChanges() 會被觸發ngOnChanges(changes)
,當接收到值時建立一份與原始資料不同的新物件1 | import { Component, OnInit, Input, Output, EventEmitter, OnChanges} from '@angular/core'; |
接著修改 Template
editTitle()
與 editExit()
不再需要傳入 $event
參數editExit()
1 | <header class="post-header"> |
因為調整了 Template ,所以 class 內的方法也需要跟著調整。
邏輯部分
originData
用途是保存原始傳入的資料,用於取消編輯時使用originData
this.originData = this.item;
因為此時還沒有 item
newTitle
editTitle()
,此時發射的 this.item
已經不是原始資料了editExit()
,實作不可變物件特性,使用原始資料重新建立新物件還原1 | import { Component, OnInit, Input, Output, EventEmitter, OnChanges} from '@angular/core'; |
現在 ArticleHeader 這個元件邏輯已經完成,可以測試一下。
FormsModule
,需匯入才可在 input 上使用雙向繫結至此程式的運作都如預期,唯獨刪除功能出狀況了,所以我們到 ArticleList 父元件調整一下程式碼。
item.id
比較即可因此程式碼修改如下:
1 | doDelete(item){ |
測試看看是否如我們預期。
透過 ngOnChanges() 的特性在屬性繫結的階段時,讓 item
屬性變成是一個「不可變的物件」,因為只是有任何新的資料進來,馬上就會重新產生新的物件,並且賦值給 item
,藉由這種方式讓 item
值與原本傳進子物件的 item
值得以脫鉤。
脫鉤後就可以在 Template 內放心地使用雙向繫結而不會影響到父物件內的原始資料,這麼做也確保了修改 item
屬性時不會一併的修改到其他元件內 item
屬性的內容。
上一篇解釋了什麼是單向資料流以及不可變的物件,這一篇要透過實作來了解。
我想要實作一個修改文章標題的功能
了解需求後立馬動手吧~
因為這個需求所以要調整 ArticleHeader 元件下的 Template 以及 class ,順便移除掉一些不相干的東西讓這個範例看起來更單純。
subject
那一層拿掉、刪除 subtitle
1 | <header class="post-header"> |
現在網頁呈現的畫面是這樣的:
邏輯是這樣的:
isEdit
的值為 True
、點擊取消編輯按鈕時 isEdit
的值為 False
為了做到這些事情,所以必須先到 ArticleHeader class 進行調整
這裡需要做的調整如下:
isEdit
用來判斷是不是處於編輯模式,預設為 False
1 | import { Component, OnInit, Input, Output, EventEmitter} from '@angular/core'; |
回到 Template 補上 ngIf
1 | <header class="post-header"> |
測試一下目前的邏輯是否正確
這邊有個一個簡單但不太建議的做法:
item.title
這麼做會直接把修改的內容直接寫回去,而需求瞬間就完成了。
但伴隨而來的缺點是
所以這個做法肯定是不太行的,得換個方式。
比較好的方式 - 透過單向資料流與不可變的物件方式
$event
editTitle
方法exitEdit
方法newTitle
ngOnInit()
時將 newTitle
賦值為 item.title
newTitle
1 | <header class="post-header"> |
1 | import { Component, OnInit, Input, Output, EventEmitter} from '@angular/core'; |
editTitle
方法時,會將 input 內的 value 值取出並賦值給 newTitle
,等待傳送給父元件editExit
方法時,將原始資料塞回 input 內的 value前置作業都做完了,剩下就是通知父元件 ArticleList 囉!
@Output()
並宣告一個 titleChange
的事件editTitle
方法時,使用 emit
發射要變更的資料給父元件1 | import { Component, OnInit, Input, Output, EventEmitter} from '@angular/core'; |
接下來就是到父元件上設定接收並做出實際的改變啦~
changeTitle
並且當這個事件被觸發時執行 doChange()
方法,使用 $event
參數接收子元件送來的資料ArticleList Template
1 | <!-- Article START--> |
到了最後這個步驟了,為了讓底下的所有子元件都知道資料物件發生改變,所以
atticleData
屬性指向的陣列要重新建立,而陣列裡面的物件如果裡面的屬性有發生修改,也要重新建立。
ArticleList class
1 | import { Component, OnInit } from '@angular/core'; |
item.title = $event.title
這樣就落入之前說的陷阱了Object.assign
方法,回傳一個新的物件並且合併 item
以及 $event
根據 MDN 的解釋,使用
Object.assign
方法時,如果在目標物件裡的屬性名稱 (key) 和來源物件的屬性名稱相同,將會被覆寫。若來源物件之間又有相同的屬性名稱,則後者會將前者覆寫。
都完成了,來測試看看吧!
糟糕程式好像出了點問題,加上幾行 console.log
觀察看看吧。
原來問題是沒有正確合併,因位子元件傳送給父元件的資料物件內的屬性名稱要與標題的屬性名稱一致才會覆蓋,因此必須回去修改。
1 | editTitle(e) { |
重新測試!
這個範例主要是練習實作一個稍微複雜一點的單向資料流與不可變物件特性的開發技巧,透過這個練習未來在開發多個元件時,可以有效降低元件與元件間的相依性也提升可維護性。
看來有空必須要常常回來這篇文章複習哩~
]]>單向資料流與不可變的物件,究竟這是什麼意思呢?
我們目前的專案結構大致如下:
假設今天有一個需求是要更改文章標題,最簡單的做法就是直接從 ArticleHeader 動手,修改 item 這個物件的屬性值,這個屬性值只要一改變,父元件 data 下的這些物件值也會跟著改變。
而雖然這個改法很簡單,不過建議最好不要這麼做。
盡量不要在子元件內直接對資料進行任何修改,否則元件間的相依性會太重,當有 Bug 產生時也不容易除錯。
最好是透過單向資料流的方式來達成。
單向資料流的意思就是,所有資料的變更永遠是從上層元件傳給下層元件。
換句話說,比較好的做法是:
JavaScript 物件本身是可變的,所以當物件發生改變時如果希望另一個元件也能知道這個物件發生改變,這在 JavaScript 內非常不容易做到。
舉例來說,在 ArticleHeader 內修改了 item 物件,那麼 ArticleBody 如何知道 ArticleHeader 內修改了 item 物件的哪個屬性呢?
要怎麼樣 Angular 這個物件確實發生了改變、讓 ArticleBody 能反映出相對應的資料呢?
Angular 確實做得到,但是這樣效能會很差。
像是在 ArticleHeader 新增了一個點擊事件,並且觸發一個 test
方法,執行 item
物件內 summary
屬性值的修改。
1 | <header class="post-header" (click)="test()"> |
1 | import { Component, OnInit, Input, Output, EventEmitter} from '@angular/core'; |
點擊後的確影響到 ArticleBody 內的資料了,但是非常不推薦這麼做。
好的作法
而這樣就是所謂的不可變的物件特性。
只要物件不管哪個屬性發生變更,通通都重新產生物件的話,在 Angular 內很容易就可以辨識出那些物件發生了變化。
舉例來說
在物件導向程式的領域,有個稱為 OCP ( Open Closed Principle ) 的原則,中文稱為開放封閉原則。這原則說的是,在進行物件導向程式設計時要能符合開放擴充但封閉修改的要素,這樣子才能把每個不同的物件獨立切開、互不干擾。
這段前言又跟這篇文章有什麼關係呢?
可整理出如下列表
而元件一層包一層的好處是,一次只要關注一個想要修改的地方就好了,不管應用程式多複雜透過元件化的架構,就可以把一份複雜的網頁切割成多個元件,每個元件只單純負責幾件事情就好。
假使想要實作出刪除文章的按鈕,想要將這個按鈕放在 ArticleHeader 的 Template 內,點擊後能夠將刪除的資訊傳給父元件 ArticleList ,最後達成刪除文章的效果。
首先在 ArticleHeader 的 Template 內做出一顆按鈕。
1 | <header class="post-header"> |
但是這樣是沒辦法刪除的,因為資料是由父元件 ArticleList 傳遞給子元件 ArticleHeader ,子元件本身並不具有這份資料。
解決辦法是將要刪除哪筆篇文章的資料往上傳遞給父元件,透過父元件刪除該篇文章。
白話來說,以刪除文章這個功能而言:
從父元件傳值給子元件是透過屬性繫結;從子元件傳值給父元件則必須透過事件繫結。
在 Angular 元件內要註冊一個事件必須先宣告一個屬性,同時宣告一個 @Output
裝飾器,跟之前介紹過的 @Input
一樣。
首先來到 ArticleHeader 的 class
內進行修改:
1 | import { Component, OnInit, Input, Output } from '@angular/core'; |
這樣就完成了 delete 事件的註冊,接著要將這個事件繫結到父元件上。
事件的名稱可以自由命名。
剛才已經在子元件內註冊了 delete 事件,此時在 ArticleList 內已經可以從自動完成中看到對應的選項。
ArticleList 內的 Template
1 | <!-- Article START--> |
當接收到
delete
事件發出的通知時,就會觸發 ArticleList 內的doDelete()
方法,而且還要讓它傳入一個$event
參數,這樣才能知道使用者點擊的是哪一篇文章的刪除按鈕。
剛才雖然已經註冊 delete
事件,但還有很多細節尚待處理。
delete
屬性需要 new 一個 EventEmitter 物件<any>
,代表要傳送的資料可以是任意型別1 | import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'; |
什麼時候發射 delete 的事件?自然是按下刪除按鈕的時候
回到 ArticleHeader 的 Template :
1 | <header class="post-header"> |
當點擊按鈕時透過點擊事件,觸發 ArticleHeader 內的
deleteArticle
方法。
因此我們必須實作deleteArticle
方法。
1 | import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'; |
當 deleteArticle
方法被觸發時,透過 delete.emit()
這個事件發射器,將 this.item
向父元件射出。
而父元件上的 $event
接收的資料就會是剛才被射出的 this.item
。
回到 ArticleList 的 class
內,實作刪除文章。
1 | import { Component, OnInit } from '@angular/core'; |
因為 atticleData
屬性並沒有明確的型別,所以使用自動完成並沒有提示可以使用什麼方法,因此手動補上 Array<any>
代表這是一個可以傳入任何資料的陣列。
最後透過 ES6 的 filter 方法篩選被刪除的文章,達成本次需求。
搞定~測試看看吧!
可以很明顯的看出,從子元件將資料傳遞給父元件是要透過比較多道手續的,但整體而言也不難理解,就花點時間好好地記住吧。
]]>每一個 Angular 元件都有自己的生命周期,元件隨時會被建立也有可能隨時被註銷,之前介紹到結構性指令的時候也有稍微提到一些。而這一篇文章主要介紹的是,元件被建立的過程中程式碼運行的順序是如何?
之前在建立元件時,常常會在元件的 class 內看到兩個方法,但是從來沒有使用過。
分別是:
constructor 是一個建構式,而建構式會在 class 被建立時,第一時間被執行,換句話說在 Angular 的生命週期裡面,這是第一個執行的程式碼。
不過幾乎不會寫任何程式在 constructor 內,因為當 constructor 執行時,元件尚未被初始化,所以是接不到任何資料的。
儘管如此, constructor 還是有特殊的用途,例如拿來做相依注入 ( DI )
從這個名稱來看,應該多少會猜到是 Angular 進行 Init 初始化後會執行的程式碼。
從 ngOnInit() 開始,該元件的初始化、該元件所有必要的屬性繫結都已經完成。
也就是說可以對目前這個元件做一些初始化的動作。
舉例來說
所以這個方法算是蠻常用到的。
當 ngOnInit() 結束後就會正式進入到 Angular 的生命週期,像是進行屬性繫結、事件繫結等等。
當父元件決定要摧毀目前這個子元件時,這個方法可以讓元件在被摧毀前執行特定的程式碼。
但是這個 Hook 的使用機會比較少,大部分的情況元件裡面的記憶體通常都會自動回收。
所以不需要特別地去寫程式處理這一塊,但是在少數的情況下像是搭配 Rxjs 去做一些非同步事件的訂閱,有些時候確實就必須在這個時間點做處理。
關於元件的生命週期還有很多沒有介紹到,同樣也是可以等用到時在了解即可,在撰寫這篇的過程中也找到一些前輩整理好的資料:
]]>承接上一篇文章,目前資料是存放於父元件的,但卻是子元件需要這份資料做輸出,那要如何將父元件的資料傳遞給子元件呢?
現在專案內發生的問題就是:
item
變數並沒有被傳進來,它仍然存在於 ArticleList 內如何把 item
變數傳入這兩個子元件呢?
如同先前提到的,要將父元件的資料傳遞到子元件,必須透過屬性繫結的方式。
意味著子元件必須要有一個屬性可以承接,於是在這邊宣告屬性 item ,並且不賦予任何值。
1 | import { Component, OnInit } from '@angular/core'; |
但這麼做還不夠,因為目前這個 item 只隸屬於 ArticleHeader 元件,在預設的情況下 item
是無法被父元件透過屬性繫結注入資料的。
還必須加入 @input() 這個 declarator 裝飾器,並且匯入相依模組才可以使用。
而這個步驟同樣也有自動完成可以使用,因此修正後如下:
1 | import { Component, OnInit, Input } from '@angular/core'; |
我們必須也在 ArticleBody 的 class 進行相同的操作,就不贅述了。
而經過上述這些步驟可以發現原本的錯誤已經不見了。
原因是我們已經定義了 item
,但是這樣還不算完成,先看一下目前網頁的狀態。
可以看到目前是沒有資料傳進來的,還需要做一些處理。
回到 ArticleList 的 Template 內,並且對兩個子元件加入屬性繫結。
居然有支援自動完成,太神啦!
1 | <!-- Article START--> |
我們就完成了子元件的屬性繫結,中括號 [] 裡面的
item
是剛才在子元件內定義的屬性,而等號後面接的是要傳入的資料也就是區域變數item
。
來測試看看吧!
至此,我們就完成了一個功能模組的建立,並且這個功能模組內有三個元件,其中父元件為 ArticleList 與它的兩個子元件 ArticleHeader 、 ArticleBody 。
Angular 應用程式的架構,就是把網頁變成一個個大大小小的元件,這是屬於元件化的架構。
在元件化的架構下只要能妥善規劃,在開發、維護、管理層面上複雜度可以大幅地降低。
]]>當專案的架構越來越龐大時,此時會將一些較相關的元件與服務元件獨立封裝成一個 Angular 的模組,像這種根據特定功能建立的模組,有時候也被稱為功能模組 (Feature Module)
假使我想要逐步地將目前部落格版型改成這張圖片上畫的那樣,該怎麼做呢?
目前專案內並沒有 Article 相關的三個元件, ArticleModule 也還沒建立,讓我們一一的完成它吧。
我要建立一個 Article 的模組,而這個模組在建立完成後,必須匯入到 AppModule 內,如此一來 Angular 才知道有 ArticleModule 的存在。
這裡有兩種方式可以建立模組,則一即可:
ng generate Module article
ng g m article
模組名稱打小寫就可以了,不用特地加上 Module 字樣
執行成功後 app 資料夾內會產生一個 article 的資料夾
比較一下 app.module 與 article.module 的內容差異
app.module
1 | import { BrowserModule } from '@angular/platform-browser'; |
article.module
1 | import { NgModule } from '@angular/core'; |
app.module 是根模組、 article.module 是功能模組
而下一步則是把 article.module 加入 app.module,讓 Angular 知道我們新增了功能模組。
調整如下:
這是因為新版的 VS Code 內建 TypeScript 新的版本,而 TypeScript 新版支援 Auto Import 的功能。
存檔後確認有無任何錯誤。
非常好,沒有任何問題產生!
剛才介紹了如何透過 Angular CLI 建立模組,但其實這些指令後面還可以加入一些參數,例如:
ng generate Module article -m app
ng g m article -m app
-m
後面可以接一個模組名稱,像是上面的指令示範。
這麼做就會使 Angular CLI 建立 article 模組後自動地向 app.module 註冊。
超棒的功能,不是嗎?
目前這個功能模組內是空的,因此要開始拆解部落格內關於 Article 的 HTML 並封裝成元件了。
將 Article 的 HTML 拆分為以下三個元件:
最後向 ArticleModule 註冊這些元件。
首先將 AppComponent 底下 Article 的部分,全部拆解成一個新的元件 - ArticleList
ng g c articleList
建立 ArticleList 元件。但此時我們不能直接輸入這道命令,因為預設 Angular CLI 會把元件註冊到 app.module ,但事實上必須將其註冊到 article.module 才正確。
因此應該先使用 CD 指令,將資料夾切換至 article 資料夾底下
接著就可以一如往常地建立元件了~
而且也會發現元件在 article.module 內被註冊了。
除了在 app.module 內註冊 article.module 外, article.module 也必須匯出元件才行!
因此修改 article.module
1 | import { NgModule } from '@angular/core'; |
接著開始搬運 AppComponent 底下 Article 的 HTML
<app-article-list>
搞定!執行看看吧~
跟剛才建立 article-list 元件一樣,必須將目錄切換過去才能開始建立元件,過程就不贅述了。
這時我們可能會想要把這些元件給匯出,畢竟記取剛才的教訓嘛~
從外部的角度而言,只需要看到 article-list 元件,並不需要看到 article-header 、 article-body 。
因為這兩個子元件是被封裝在 article-list 元件內的,所以僅匯出 article-list 元件即可。
接著就繼續搬動 HTML 囉,過程就不贅述了
最後於 article-list.component.html 內補上對應的元件標籤就完成了。
至此已經將架構大致完成,但細部還需要做調整。
此時網頁會是壞掉的,因為目前 article 的資料仍停留在 article-list 並未向下傳遞,
下一篇將介紹如何把資料往子元件傳遞。
]]>Angular 是採用元件化模組開發的框架,可以想像就是不同大大小小元件堆砌而成的網頁,這樣的情況下元件架構又是如何呢?
從這張圖可以看出完整個的 Angular 應用程式,是由不同的元件所組成。
最上層的根元件預設名稱為 AppComponent ,而底下可能包含了 Header 元件與 ArticleList 元件。
也就是說在 Angular 的世界,網頁是由一層層的元件所組成。
而元件跟元件之間就有父子的關係,像是
元件與元件之間是可以互相溝通的,例如:
相反的,如果是子元件往父元件溝通呢?
進階的元件與元件的溝通會藉由建立服務元件來達成。
而服務元件有可能會透過一種稱為相依注入 ( Dependency Injection ) 簡稱 DI 的技術,把預先設計好的服務元件直接注入到某個特定得元件內。
舉例來說,可以透過 DI 技術將某個服務元件注入到 ArticleList 元件內,或者是 ArticleHeader 元件等等。
而服務元件內封裝的就是不同元件之間需要共用的資料、方法等等
雖然這個架構看起來相當不錯,當網站越來越大時,通常會將相關的元件像是:
把這些相關的元件封裝起來,獨立成為一個 Angular 的模組,像這種根據特定功能建立的模組,有時候也被稱為功能模組 (Feature Module)。
好的,現在我了解在 Angular 內不同元件間是如何溝通的了,而且也覺得蠻熟悉的,儘管方法可能完全不同,但卻有點類似。
好了,讓我們繼續探索 Angular 吧。
]]>從我開始學習 Angular 到現在雖然不是很久,但也有一段時間了。蠻容易在 Template 內跑出 TypeScript 的型別錯誤,那麼又該如何避免呢?
像這樣,這種類似的情況其實蠻容易發生的,很容易看到類似像這樣 TypeScript 型別的錯誤。
這段錯誤的意思是說,這個 item 的區域變數並沒有定義它有個屬性叫做 subjec ,所以它認不得這是什麼。
這種情況下有兩個解法:
類型轉換函數的用法的則是在一個區域變數或者屬性的前後加上一個 $any()
的函式將其包覆,而官網的範例是這樣的:
1 | <!-- Accessing an undeclared member --> |
透過這個函式回傳的型別的一定是 any 型別,這樣也能使 Angular 跳過 TypeScript 型別的檢查
於是我們依樣畫葫蘆的嘗試看看
這時會發現出現錯誤 Unknown Method ,原因是目前 Angular language service 這個擴充套件還不支援 $any(),未來等這個套件更新後這個問題應該會自然消失。
不過網頁仍然可以正常運行沒有錯誤,代表這個語法其實是可以用的。
如果真的覺得這個錯誤很礙眼,那還是使用第一種介紹的方法吧~
]]>安全導覽運算子 (Safe Navigation Operator) ,又是一個讓人摸不著頭緒的新名詞,讓我們一起看看吧。
直接在官網上搜尋 「template syntax」,這被官網歸納在模板的語法,相關說明如下:
這個運算子可以使用在 Template 內,語法相當的特別是一個問號在加上一個點 「?.」
直接從範例來了解如何使用吧!
app.component.ts
1 | atticleData = [ |
因為資料結構改變了,所以 Template 也必須跟著調整
Template
1 | <h2 class="post-title"> |
測試看看是否仍能正常運行。
此時,如果還需要額外輸出一個副標題的話該怎麼做?
Template
1 | <h2 class="post-title"> |
接著回到 app.component.ts 補一下第一筆資料的 subtitle 。
運行開發伺服器,觀察畫面呈現。
很好,一切都如預期執行。
前置作業準備完成後,現在要模擬另一種情境。
這些資料在正式開發的時候,都會藉由 API 向後端取得資料,而從前端的角度來看,沒有辦法確保後端給我們的資料一定是完整的。
舉例來說,可能第一筆資料內有 subject 屬性,但第二筆就沒有 subject 屬性了,此時就會產生問題。
像是這樣,第一筆資料之後的文章都沒有顯示出來。
該怎麼辦呢?
這時候安全導覽運算子 (Safe Navigation Operator)就派上用場啦!
使用方式就是在可能會是 null
或是 undefined
的這些屬性或者是變數名稱後面加上一個問號而已。
Template
1 | <h2 class="post-title"> |
在這個情境裡 subject
不存在,所以當嘗試存取 subject
時會跳出 undefined
,而 undefined
並不是物件,所以沒有屬性 subtitle
。
因此必須於
subject
後面補上一個?
接著運行開發伺服器,觀察情況吧!
搞定!只要在屬性後方補上問號,就會形成安全導覽屬性。
這個語法可以幫助 Angular 去判斷如果該屬性為 null
或是 undefined
的話,會直接回傳 null
就不會嘗試讀取這個屬性,也就不會出錯了。
這就是安全導覽運算子好用的地方,可以避免 Template 出現諸如此類的問題,另外安全導覽運算子只能使用在 Template 內。
]]>這是一個我之前沒聽過的新名詞 Pipes 管線元件 ( Pipes Component ),究竟這是一個什麼樣的元件呢?
當我們對一項東西不了解的時候,很直覺的我們會想要上網去查資料。
幸運的, Angular 官方就可以找到相關資訊,而且還是中文的
Pipes 管線元件主要是讓我們在透過內嵌繫結或者是屬性繫結時可以透過 Pipes 的符號
|
將我們原本的輸出丟給另一個 Pipes 元件處理,再把新的結果輸出到畫面上。
聽到這裡,我又有一種似曾相似的感覺。
在 Vue 裡面可以使用 filters ,同樣也是會使用到 Pipes 的符號
|
將原本的輸出丟給某個東西處理後,在把新的結果呈現在畫面上。
扯遠了。
Angular 內有幾個內建的 Pipes 元件
未羅列出全部,詳細請參考這裡
這幾個 Pipes 元件預設就可以讓我們使用在任何的 Template 上,若這些內建的 Pipes 元件不符合需求,也可以自行撰寫 Pipes 元件哦。
顧名思義這個 UperCase 的 Pipes 元件,就是幫我們把原本輸出有英文的部分全部轉換成大寫。
這是還沒有套用 UperCase 前,標題的英文有大寫有小寫
因此我要把這個 Pipes 元件套用到目前的 Template 上。
Template
1 | <h2 class="post-title"> |
如此簡單的一行就搞定,而且有支援自動完成功能,很方便!
與 UperCase 相同,顧名思義就是有英文的部分全部轉換成小寫,使用方式也非常簡單:
Template
1 | <h2 class="post-title"> |
關於 DecimalPipe 的使用同樣可以在官方文件找到
使用方式如:
1 | {{ value_expression | number [ : digitsInfo [ : locale ] ] }} |
{minIntegerDigits}.{minFractionDigits}-{maxFractionDigits}
因此可以按照官方提供的範例,複製一份並且在 Template 內隨意找個地方貼上玩看看:
Template
1 | <div> |
Template 有用到 e
以及 pi
這兩個內嵌繫結,因此必須在 class 內補上這兩個屬性。
1 | export class AppComponent { |
運行開發伺服器,看看效果如何~
3.1-5
,對照上面的格式與參數解釋後,得到結果 e 為 002.71828可透過修改第一個參數達成想要的數字格式。
不僅僅第一個參數可以利用,配合第二個參數一起使用才是王道。
而這個 Pipes 元件好玩的地方是,它的名稱叫做 DecimalPipe ,但實際使用卻是輸入 number ,這是容易搞混的地方要特別注意。
這是個貨幣格式的 Pipe 元件,官網文件如下:
用法跟先前介紹的 DecimalPipe 蠻類似的,使用方式如下:
使用方式如:
1 | {{ value_expression | currency [ : currencyCode [ : display [ : digitsInfo [ : locale ] ] ] ] }} |
{minIntegerDigits}.{minFractionDigits}-{maxFractionDigits}
直接參考官網範例:
1 | @Component({ |
這部分因為跟 DecimalPipe 很類似,所以就不一個個解釋了。
這是一個百分比的 Pipe 元件,官網文件如下:
使用方式如:
1 | {{ value_expression | percent [ : digitsInfo [ : locale ] ] }} |
{minIntegerDigits}.{minFractionDigits}-{maxFractionDigits}
直接參考官網範例:
1 | @Component({ |
從這邊可以發現, DecimalPipe 、 CurrencyPipe 、 PercentPipe 使用方式感覺上其實都差不多
這是一個關於日期的 Pipe 元件,關於這個元件的官網說明如下:
使用方式如:
1 | {{ value_expression | date [ : format [ : timezone [ : locale ] ] ] }} |
接著利用 DatePipe 來修改部落格文章上的日期格式吧
Template
1 | <span class="post-date"><i class="glyphicon glyphicon-calendar"></i>{{item.date|date:'yyyy-MM-dd HH:mm'}}</span> |
這樣子的用法是直接轉換成我們希望的格式,但其實 format 參數內有一些已經預先定義好的格式可以使用,例如:
Template
1 | <span class="post-date"><i class="glyphicon glyphicon-calendar"></i>{{item.date|date:'shortDate'}}</span> |
JsonPipe 可以把任意值全部轉換成 Json 的格式,所以這些值可以是字串、數字、物件。
而官網的文件如下:
JsonPipe 的功能主要是拿來偵錯,像是輸出之前在練習 ngFor 時的用到的資料。
Template
1 | <!-- Article START--> |
如果直接把 item
進行內嵌繫結輸出的話,只會得到奇怪的結果
如果想要得到
item
物件序列化之後的 Json 資料,則可以使用 JsonPipe 來達成。
Template
1 | <pre>{{item|json}}</pre> |
Slice 這個單字有切割的意思,所以這個 SlicePipe 可以幫助我們把某個物件、集合切割出其中的某一塊資料出來。
而官網的文件如下:
使用方式如:
1 | {{ value_expression | slice : start [ : end ] }} |
看完這些敘述似乎有點混亂,可以先試著把 SlicePipe 用在部落格的標題看看情況。
順帶一提, Pipe 與 Pipe 之間是可以串接的。
Template
1 | <h2 class="post-title"> |
這麼寫的意思代表,我希望標題的英文可以是小寫,且字數可以從 0 取到第 20 的位置
觀察一下情況
可以看到標題顯示到 visual st 就結束了,因為前面加起來剛好是 20 個字元。
如果想要取標題從後面數來第 20 個字元後的內容,也是辦的到的:
Template
1 | <h2 class="post-title"> |
可以看到標題顯示從 「o code 如何避免顯示惱人的偵錯訊息」,這邊數來剛好 20 個字元。
以上是 SlicePipe 用在字串上的範例 ,而 SlicePipe 還可以用在陣列上。
舉例來說,之前在練習 ngFor 時傳入的資料總共有六筆,而傳入的資料型態是個陣列。
這代表可以將 SlicePipe 用在這個地方,像是一頁只顯示二筆資料這樣。
Template
1 | <article class="post" id="post{{idx}}" *ngFor="let item of atticleData|slice:0:2; let idx = index"> |
介紹了這麼多的 Pipes 元件,一時之間想必很難完全記起來,好在這些資料全部都可以在官方文件上查到,而且全部都具有範例程式碼。
所以不用死背這些東西,只需要記幾個最常用的像是 DatePipe 、 PercentPipe 、 CurrencyPipe 就可以了,其餘的等有用到再查也不遲。
]]>總算到介紹到最後一個 Angular 的指令 - 結構型指令 (Structural Directives) 啦,打起精神繼續往下學習吧!
這種結構型的指令會透過新增或刪除 DOM 的元素動態改變 DOM 的結構,內建有三種語法:
相信看到這些語法大概也猜得出七八成是在做什麼的,以下一一示範如何使用。
ngIf 的用法相當簡單,可以透過這個指令動態的新增或者是刪除一整段 HTML 的內容。
假設這個 ICON 區塊會根據 Counter 的數字動態的顯示或隱藏,那該怎麼做呢?
首先找到 Template 內對應的 HTML 區塊,並進行修改
1 | <div id="social-icons" class="pull-right social-icon" *ngIf="counter % 2 == 0"> |
特別的是 ngIf 前面必須加上一個 * 號,這個星號是結構型指令專屬的語法,算是語法糖的一種。
只要是結構型指令,記得加上 * 號就對了。,不過我們也不需要真的死背,可以善用 code snippet 來輔助。
而 *ngIf
的等號後方同樣也是接布林值,因此在這裡我們填入 counter % 2 == 0
這樣就可以了。
測試看看吧!
這邊要強調的是 ngIf 是真的操作 DOM 元素動態改變結構進行新增或是刪除,也就是當回傳 False 時該區塊是完全不存在網頁中的,並非單純的隱藏。
使用 ngif 時,雖然語法相當的簡單,可是有生命週期的細節需要注意。
使用 ngif 新增或刪除某個區塊的 HTML 時,那些區塊可能含有其他的 Component
*ngif = false
時 Component 會一起被刪除*ngif = True
時 Component 被新增回來也就是說這樣的情況下會影響到 Component 的生命週期,當 Component 被刪除時生命週期也就跟著結束了。
因此下一次新增回來的 Component 狀態肯定會跟之前的不同,這點需要特別注意
這個 Component 狀態好比 HeaderComponent 內的 counter ,假如目前
counter = 5
當元件被刪除又新增後,這個時候的counter
已經被重置了。
ngSwitch 也是個結構型指令,假設我們有個需求是這樣:
counter % 2
產生的不同的餘數來切換首先找到 Template 內對應的 HTML 區塊,並進行修改
1 | <div id="social-icons" class="pull-right social-icon"> |
[ngSwitch]
後面接的是條件,在這裡我們的條件設定為 counter % 2
,而 *ngSwitchCase
後面接的是「當遇到 0 或 1 時要顯示哪些東西」,那如果都不符合的話的則顯示 *ngSwitchDefault
的結果。
存檔測試看看吧!
成功是成功了,但這時會發現使用 ngSwitch 導致 HTML 結構不一致。
這該怎麼修正呢?
可以使用
ng-container
來取代使用 ngSwitch 時產生的 div 標籤
1 | <div id="social-icons" class="pull-right social-icon"> |
再次觀察 DOM 結構
此時就沒有多餘的 DIV 標籤了。
從這邊我們知道,當使用結構型指令時可以使用 ng-container 標籤,使其不會產生出額外的 HTML 標籤。
ngFor 的功能相當強大,使用的時機也相當頻繁。
舉例來說,當我們串接 API 的時候,會取得一份資料。此時可能會透過迴圈的方式顯示在畫面上,這時後就有機會用上 ngFor 。
因為目前網頁的文章是寫死的,接下來要透過 ngFor 動態的把文章的資料呈現在網頁上。
先整理好要給 ngFor 跑的陣列資料,可以先寫死一份假資料在 app.component.ts 內。
app.component.ts
1 | import { Component } from '@angular/core'; |
在這邊因為 tslint 要求我把所有的雙引號改成單引號,為了開發方便,我先暫時把 tslint 給關閉。
就這樣我們新增了一個 atticleData
屬性,裡面是一個陣列,陣列內又有一個個物件,而每一個物件又有相關的屬性。
接著我們透過 ngFor 這個結構型指令來把這些通通顯示在畫面上吧!
回過頭來處理 HTML 部分,同樣我們使用 code snippet 幫助撰寫程式碼
1 | <!-- Article START--> |
這裡的 *ngFor
後面接的語法有點像是 JavaScript 中 ForEach() 的感覺, item
代表當前陣列的元素而 atticleData
則是要遍歷的目標陣列。
蠻像 Vue 中的 v-for
指令的,如果有學習過 Vue 肯定對這個部分不陌生。
運行開發伺服器,觀察結果吧!
至此,我們已經成功地透過 ngFor 快速的把資料轉換成網頁的內容,而且程式碼相當的簡潔。
不過還需要修正最後一個小地方,那就是文章 summary 部分的 HTML 標籤居然直接被輸出了,這該怎麼辦呢?
先把 HTML 中的 這段內嵌繫結刪除,並且套用屬性繫結
1 | <!-- Article START--> |
這一段的作法是將屬性繫結在 section 標籤的 DOM 物件內的 innerHTML 屬性上,這麼一來 HTML 的標籤就會直接寫入 innerHTML 這個屬性內。
如此一來就全部搞定啦~
值得注意的是這個作法是有風險的,因為這些 HTML 可能含有一些惡意的內容,可能會導致 Cross-Site Script 的攻擊。
不過 Angular 有幫我們做到一些很基本的防護,例如在資料的部分偷偷的加一些料:
1 | atticleData = [ |
回到網頁上會發現 Angular 自動的把 script 標籤過濾掉了,所以什麼都事情都沒發生,而且還很智慧的顯示這一段提示
意思是 Angular 幫我們從 HTML內了清除了一些有害的內容,而後面的連結則是關於 XSS 的資安文件。
最後我們要替每一篇文章的 id 補上索引值,而 ngFor 裡面提供了一個好用的方法。
1 | <!-- Article START--> |
只有在 ngFor 指令下才可以使用,能自行宣告一個變數 idx
,並且賦值為 index
,如此一來就能取得目前的索引值。
這時的文章 id 就會按照索引值增加了。
當然這邊也可以直接使用屬性繫結把資料內的 id 綁上去即可,只是這邊要介紹索引值的用法。
總算是介紹完 Angular 的三種類型的指令了,有沒有覺得這些指令都似曾相似呢?就我來說的話肯定是有的,而大部分的用法都蠻接近的,只是細節上有不同之處。
希望能早日把 Angular 給掌握起來!
]]>學完元件型指令後,接著介紹第二種 Angular 指令 - 屬性型指令 (Attribute Directives)
這種 Directives 有個特性,就是本身不會有自己的 Template ,但是套用這個 Directives 的地方,會修改那一個元素或者是那一個標籤的外觀或者是行為。
在 Angular 內建的屬性型指令內有三種:
透過第二與第三個 Directives 可以很容易地去修改現有 HTML 的外觀、 CSS 的 Style 、 或動態的套用一些 class 。
現在我們替 HeaderComponent 內加入一個新功能,當點擊網站 LOGO 時,計算被點了幾下。
class
內做調整,加入 counter
屬性並補上點擊時 counter++ :1 | export class HeaderComponent implements OnInit { |
1 | <div class="pull-left"> |
搞定!運行開發伺服器確認功能是否正常運作
修改 HeaderComponent 的 Template
1 | <div class="pull-left"> |
這邊做了一個 ngStyle 的屬性繫結,但是 ngStyle 並不是 h3 標籤的 Property 也不是 Attribute 。
ngStyle 本身就是一個 Diretive ,這個 Diretive 可以透過 [] 繫結一個物件,然後這個物件的屬性就是要套用的 CSS 的 Style ,而值就是我們希望套用的值。
因此再次運行開發伺服器確認功能是否正常運作
這樣就有隨著點擊次數字體越來越大的感覺了!
值得注意的是, ngStyle 後面跟著的是一個物件,這代表其實可以傳入多組 CSS 的 Style ,但是通通寫在 HTML 內會顯得很難看,所以我們也可以這麼處理:
在 class
內撰寫方法並回傳
1 | <div class="pull-left"> |
1 | export class HeaderComponent implements OnInit { |
這麼處理的結果會跟剛才的結果一模一樣。
或者是直接透過屬性
1 | <div class="pull-left"> |
1 | export class HeaderComponent implements OnInit { |
這麼做也是沒問題的!
ngStyle 還有更簡單的使用方式,像是:
1 | <div class="pull-left"> |
直接在中括號內輸入 style
後面接上 .
直接加上想要套用的樣式即可,而等號後面則與剛才設定相同。
而如果你想要同時套用多種,可以這麼做:
1 | <div class="pull-left"> |
而這個方法也可以使用 class 內的屬性或方法使其更易讀
1 | <div class="pull-left"> |
1 | export class HeaderComponent implements OnInit { |
執行結果如下
這些方式可以選擇一種使用即可,看哪個順手就用哪個吧。
如果已經知道如何使用 ngStyle ,那麼 ngClass 肯定難不倒我們,兩者的用法幾乎是一樣的。
我們將剛才設定過 ngStyle 的地方全部刪除,並且加上 ngClass :
1 | <div class="pull-left"> |
可以看到整體結構上基本同於 ngStyle ,等號後方一樣是接收一個物件,因此剛才介紹的簡化方法 (寫在 class 的屬性、方法內) 同樣適用於 ngClass 。
於是可以修改成這樣
1 | <div class="pull-left"> |
物件屬性就是要設定的 className , 而不同於 ngStyle 的是,這個地方的值要輸入布林值。
接著新增 highlight 這個 className 吧
header.component.scss
1 | .highlight{ |
搞定,接著測試看看吧!
聰明如你,想必已經猜到了如何使用,不囉嗦直接看範例!
1 | <div class="pull-left"> |
等號後方直接給布林值就可以了,這樣是不是更簡單了呢?
也就是說等號後方可以直接寫一個布林值、或者透過 class 內的屬性、方法回傳布林值,這些都是合法的。只要是
True
就套用這個 className ,反之則不套用。
終於介紹完屬性型指令了,整理這些東西的時候不禁讓我回想起 Vue 裡面的 :class
、 :style
這些東西,而新學會的這些東西也是相當的好記,語法本身也是蠻簡單的。
接下來就是 Angular 的第三種指令 - 結構型指令囉!
]]>接下來要介紹三種不同的 Angular 指令,而這裡的指令就是之前一直提到的 Directives 。 Angular 指令有元件型、屬性型、結構型這三種指令,接下來會一一介紹當中差異。
之前建立的 HeaderComponent 就是屬於元件型指令,它是 Component 同時也是 Directives 。
接下來再次新建一個 Component 練習吧,輸入 ng g c footer
,這次我們建立 FooterComponent 。
最後別忘了在 AppComponent 內輸入 FooterComponent 的元件型指令,也就是
1 | <app-footer></app-footer> |
接著測試看看是否正常運行
觀察 FooterComponent 的 class
,可以發現只要這是一個 Conponent ,那麼就一定會有一個 裝飾器 ( decorater ) 宣告在上方,如:
FooterComponent
1 | import { Component, OnInit } from '@angular/core'; |
@
開頭,後面接一個 decorater 的名稱。selector
選取器templateUrl
- 指出 template 路徑在哪裡,如果不輸入路徑也可以使用 template
屬性,直接輸入該 template 內容即可styleUrl
- 與 templateUrl
不同,後面接的是一個陣列的型態,意思也就是可以輸入多個不同的 CSS 檔可以發現這麼做之後,網頁的 footer 仍然是正常的。
不過還是建議獨立成一個檔案比較好,畢竟這樣子實在是不容易閱讀。
事實上裝飾器內可以使用的屬性還有非常多,用到時再查文件就可以了。
這個檔案是由 Angular CLI 自動幫我們產生的,因此預設是空白的,讓我們隨意寫一點樣式吧。
1 | <p class="text-muted credit"> |
調整 credit
,這邊為了方便示範所以直接使用 !important
強制覆蓋。
1 | .credit{ |
接著執行開發伺服器查看效果:
之前有提到,在這裡設定的 CSS 只會套用到該元件,不會影響到其他元件,超出 FooterComponent 範圍外的通通不會吃到這裡設定的 CSS 樣式。
這可以透過開發者工具觀察 Angular 是如何辦到的!
Angular 在這些動態產生的 diretive 下所有的標籤全都被加上了一個底線開頭的 attribute 屬性,而且有一定的規律可循。
像是根元件上的 _nghost-tij-c0
, 0 是個編號,只要遇到不重複的 Component 就會有一個唯一的、不重複的編號。
.credit 類別被動態注入到頁面上時,它的選取器並不是單純的 .credit ,而是 .credit 後面加上中括號包覆 attribute 的選取器。
而這個 attribute 剛才說過是個唯一的值,這也是為什麼寫在元件內的 CSS 不會影響到其他元件的原因。
可以在裝飾器內新增一個屬性 encapsulation
,要把這部分的封裝調整成 none
,具體設定如下:
1 | @Component({ |
Emulated
,也就是模擬的意思,就是指 CSS Style 的封裝,none
,意思是不需要進行 CSS Style 的封裝。如此一來,這個 .credit 就會渲染到整個網頁了。
設定後,現在當 .credit 類別被動態注入到頁面上時,它的選取器已經是單純的 .credit 了,也就是說這個網頁所有用到 .credit 這個 class 都會吃到字體顏色變成黃色的設定。
以上就是元件型指令在使用上可能會用到的一些設定與注意事項,接下來要介紹的是屬性型指令。
]]>結束資料繫結的學習後,接著介紹關於範本參考變數 ( Template Reference Variables ) ,讓我們一起看看吧!
從字面上來解讀,這是一種可以用在 Template 上的一種設定變數的方式。
它的語法就是在任意的標籤裡面使用一個 # 字號加上一個變數名稱,如 #name
。
以下語法完全相等,差別在於使用 # 字號是語法糖
#name
ref-name
我們將修改上次那份計算輸入字數的 Template ,觀察當中的差異。
需要特別注意的是,替範本參考變數取名時,盡量不要與之前設定過的屬性 ( Property ) 重複名稱,否則可能會有一些問題產生。
Template
1 | <div class="widget-content"> |
於是在普通的 input 上設定一組範本參考變數 #tinputValue
,此時 #tinputValue
的值會是這個 input 的 DOM 物件。
因此可以直接操作 DOM 物件,取得當中的 value
屬性並且計算其長度,而這個部分就是所謂的範本參考變數。
而且可在任何標籤上,設定唯一的範本參考變數,只要名稱不同即可。
範本參考變數除了使用在一般的 HTML 標籤上,也可以使用在 Directive ,具體來說怎麼做呢?
ng g c header
建立新的 Component ,並且取名叫 headerclass
內的程式碼調整完畢後 header.component.ts 內容如下
1 | import { Component, OnInit } from '@angular/core'; |
接著運行開發伺服器,確認 HeaderComponent 是否正確執行。
看來是設定成功了,接著我們要在這個 Directive 上使用範本參考變數。
首先一樣在 <app-header #tHeader></app-header>
上加入 #tHeader
,意思是建立一個範本參考變數。
接著我們隨意找一個 HTML 標籤上加入事件繫結,如:
1 | <section class="container" (click)="tHeader."> |
這時會發現 VS Code 會跳出 HeaderComponent 內所有的屬性給我們選取。
之前說如果把範本參考變數使用在普通的 HTML 標籤上,則代表的值是該標籤的 DOM 物件。
而如果把範本參考變數使用在 directive 上,則代表的值就是背後所對應的 Component ,在這個範例內也就是 HeaderComponent 。
因此可以存取這個 Component 底下所有的屬性,如:
1 | <section class="container" (click)="tHeader.title='Title Change'"> |
接著運行開發伺服器,並且任意的點一下,觀察標題是否改變。
以上就是範本參考變數在 Directive 上的應用,值得一提的是範本參考變數只能在範本( Template ) 內使用,在 Component 內預設是沒辦法使用的。不過還是有一些進階的技巧可以幫助我們在 Component 中取用範本參考變數,但眼下還是先學習其他基礎的部分吧,進階技巧等之後碰到再來談。
directive 看了很多次但卻不太清楚是什麼意思,接下來要好好針對這個名詞做了解囉。
]]>終於來到資料繫結的第四種方法 - 雙向繫結 ( Two-way Binding ) 了,而這種繫結方式跟其他三種有何不同呢?
到目前為止介紹了三種資料繫結的方法,分別是內嵌繫結、屬性繫結、事件繫結,這三種的前兩種都是屬於從 Component 單向將資料傳送到 Template 的繫結方式。而事件繫結也算是單向的繫結方式,這是從 Template 內透過瀏覽器的事件觸發之後呼叫 Component 內的方法。
而我們這次要介紹的繫結方式就是雙向的繫結方式,這種繫結方式會自動的做到屬性繫結與事件繫結,也因此我們寫的程式碼會更少、更精簡。
我們將之前的練習改寫,見識見識雙向繫結的威力。
template
1 | <div class="widget-content"> |
要使用雙向繫結的話辦法相當簡單,使用
[(ngModel)]
語法後面接一個想要雙向繫結並且在 component 內的屬性即可。
component
1 | import { Component } from '@angular/core'; |
當運行開發伺服器後會發現, Angular 又掛掉了。
意思就是 Angular 看不懂 ngModel 這個屬性是什麼。
也就是說如果我們想要使用雙向繫結,還有一個步驟要做,那就是把 Angular 表單的模組匯入到 AppModule 內。
於是打開 AppModule 這支檔案進行修改
1 | import { BrowserModule } from '@angular/platform-browser'; |
修改完成後,再次運行開發伺服器,發現可以成功運行網頁了,但我們還需要調整程式碼的部分。
可以透過內嵌繫結,測試一下剛才設置的雙向繫結 inputValue
有沒有成功
template
1 | <div class="widget-content"> |
藉由這個測試知道,目前這個 input 的輸入框跟 component 內的 inputValue
已經建立了雙向繫結。
我們已經成功地建立了雙向繫結,接著把其他地方的程式碼也修改一下吧。
template
1 | <div class="widget-content"> |
component
1 | import { Component } from '@angular/core'; |
搞定!最後運行開發伺服器,測試功能是否仍然正常。
雙向繫結既然這麼好用,我們是不是應該不管怎麼樣都使用雙向繫結呢?
事實上,過度濫用雙向繫結也會給網頁效能造成一些負擔,可能導致網頁的反應會變慢,但具體來說還是得看實際狀況而定就是了。
到這裡我們已經學會了 Angular 內四種資料繫結的方式了,接下來要介紹的是範本參考變數。
]]>學習完三種資料繫結方法後,雖然還有一種尚未提到,但是我們已經可以嘗試寫一點有趣的東西了,因此弄了 2 題小問題來測試自己有沒有完全吸收。
如圖所示,希望實作出兩個小功能
沒有遇到太多的困難,主要是查詢有什麼事件可以使用。
因此調整後的 template 如下:
1 | <div class="widget-content"> |
程式碼的部分,卡最久的地方卻是「不知道怎麼定義型別符合 TypeScript 的規則又能清除 input」。
因為我以為 $event.target
要填的是 KeyboardEvent
,因為我使用的是 onkeydown 事件,後來發現不是這麼一回事。而是要根據傳入參數的型別來寫,折騰了好一陣才知道要寫 HTMLInputElement
。
其實可以不要定義型別就沒有這些問題。但我想既然都要寫 TypeScript 了,就還是好好的學習如何正確撰寫 TypeScript ,才是正道。
嗯,看起來是沒有什麼大問題,只是還不太熟 TypeScript 以及 Angular 的語法。
接下來要講到最後一種繫結方式囉!
]]>在 Angular 內,第三種資料繫結的方法是事件繫結 ( Event Binding ),具體來說怎麼實踐,讓我們繼續看下去。
這是我們目前網頁上的一張 Q 版的 chrome LOGO ,假設我們要在這張圖片加上 Click 點擊事件,點下去之後網站標題會跟著改變。
在事件中間加上 「-」,代表這是 Angular 的語法,並且在雙引號內放入 function ,但是在 Angular 內並沒有 function 只有類別,而類別內只有屬性以及方法。
template
1 | <img on-click="changeTitle()" [title]="title" [src]="imgUrl" [attr.data-title]="title" class="pull-left logo"> |
讓我們宣告一個方法叫做 changeTitle
。
component
1 | import { Component } from '@angular/core'; |
除了上述的作法外,也可以使用這種方式添加事件繫結。
template
1 | <img (click)="changeTitle()" [title]="title" [src]="imgUrl" [attr.data-title]="title" class="pull-left logo"> |
這樣的方式跟上一篇提到的屬性繫結是不是有點相似呢?
差別在於屬性繫結是使用中括號 [] 表示,而事件繫結是使用小括號 () 表示。
而大部分的 Angular 開發者都是使用第二種方法來進行事件綁定,較少使用第一種方法。
標題的確被更新了,但這是怎麼辦到的呢?
我們在 LOGO 上執行了 Click 動作,然後註冊 Angular 的事件繫結,而這個事件繫結到了 changeTitle
方法,因此當有人點了 LOGO 時,就會跳到 AppComponent 內去執行 changeTitle
方法。
而這個方法會藉由執行 this.title = 'changeTitle';
來變更 class 內的 title
屬性,又因為先前我們對網站標題使用了內嵌繫結,所以當 class 內的 title
屬性有異動時, Angular 就會管理頁面 DOM 的狀態,也就是所有頁面中有繫結 title
屬性的地方一起改變。
當撰寫事件繫結時必須要傳入一個方法,預設可以不用傳入任何參數,但是在這裡確實可以傳入一個很特別的參數 $event
,讓我們觀察看看。
這個 $event
可以幫助我們取得事件的詳細資訊
template
1 | <img (click)="changeTitle($event)" [title]="title" [src]="imgUrl" [attr.data-title]="title" class="pull-left logo"> |
component
1 | import { Component } from '@angular/core'; |
接著運行開發伺服器,按下 F12 開啟開發者工具並點擊 LOGO 圖案。
可以看到跑出了很多東西,而這個 MouseEvent 其實就是 DOM 的 MouseEvent ,因此這一次觸發的滑鼠事件內可以找到相當多的屬性。
target
屬性代表的是剛剛點下去的那個 DOM 物件,例如說剛剛我們是點擊 img 觸發的,也就是說它的 target
屬性會是:
altkey
屬性代表的是點擊時有沒有按下 「alt」 這個按鍵,因此我們可以替剛剛那個範例加上一個新的需求,必須要按下 「alt」 這個按鍵才可以更改標題。
component
1 | import { Component } from '@angular/core'; |
我在這邊踩到了一的雷,那就是 altKey 的 K 是大寫,因此這部分要特別注意英文的大小寫部分。
搞定!運行開發伺服器測試看看
但是這個部分可以有更好的寫法,那就是使用具有型別的 $event 參數
在上一個範例裡,因為英文的大小寫導致程式沒有按我們預期的跑,但我們現在是使用 TypeScript 進行開發,所以我們可以利用 TypeScript 帶來的好處,利用型別來標註參數的型別,具體來說我們可以這麼做:
$event
的內容其實是 MouseEvent因此可以在 Component 內這麼寫:
1 | import { Component } from '@angular/core'; |
接著神奇的事情發生了,當我們輸入 $event
並按下 .
時,VS Code 會列出所有 MouseEvent 內所有可以選擇的屬性。
我們知道可以在事件繫結中傳入 $event
參數,但其實這個部分可以更進一步的改寫,並且結合剛才的提到的型別,例如:
template
1 | <img (click)="changeTitle($event.altKey)" [title]="title" [src]="imgUrl" [attr.data-title]="title" class="pull-left logo"> |
可以直接在這個地方就傳入
$event.altKey
。
component
1 | import { Component } from '@angular/core'; |
然而因為 $event.altKey
的值是 true 或 false ,因此型別是布林。
接著可以再次確認運作是否正常。
在事件繫結中,有些同樣的事情會有不同的方法可以實作,至於要用哪種方式撰寫就見仁見智了。對我來說,怎麼樣的寫法是易懂又容易維護的,那就是值得學習的好方法。
]]>學習完內嵌繫結後,接著介紹到 Angular 第二種資料繫結方式 屬性繫結 ( Property Binding )。話不多說讓我們馬上開始吧!
值得注意的是屬性繫結 ( Property Binding ) 的屬性英文單字是 Property ,而 Attribute 的中文翻譯也叫做屬性。因此很有可能跟上一篇介紹到的內嵌繫結也可以應用在 HTML 標籤的屬性 ( Attribute ) 上搞混。
實際看個例子,這次我們不使用內嵌繫結來改變 a
標籤上的 href
屬性:
component
1 | import { Component } from '@angular/core'; |
template
1 | <div class="pull-left"> |
打開開發伺服器,觀察 a
標籤上的 href
屬性,發現跟內嵌繫結是一模一樣的。
同理,也可以對 img 圖片做屬性繫結:
component
1 | import { Component } from '@angular/core'; |
template
1 | <img [title]="title" [src]="imgUrl" class="pull-left logo"> |
接下來透過一些範例,釐清 Property 與 Attribute 的差異。
一般而言,要擴充 HTML 標籤上的 Attribute 會使用 data-*
自由的擴充 HTML 標籤上的 Attribute ,例如:
template
1 | <img [title]="title" [src]="imgUrl" data-title="" class="pull-left logo"> |
接著我們可以實驗看看自訂的 HTML Attribute 可不可以使用屬性繫結。
template
1 | <img [title]="title" [src]="imgUrl" [data-title]="title" class="pull-left logo"> |
運行開發伺服器,可以發現錯誤訊息
這段訊息大意上是說,綁定 Property 的時候, Angular 發現
img
底下並沒有data-title
這個 Property ,因為data-title
是我們自訂的 Attribute ,因此不能任意的使用屬性繫結,儘管它們中文翻譯都是屬性。
運行開發伺服器,並且使用開發工具觀察 img
標籤,可以看到非常多的 attribute。
這個標籤裡面 class
、 title
、 src
都是 Attribute
然而什麼是 Property 呢?
如果想知道
img
標籤的 Property ,可以查詢img
的 DOM 物件所有的 Property 。
透過開發者工具查詢
可以點選 Properties 頁籤,切換過去後就可以看到 img
標籤下所有的 Property 了。
而在這裡面可以發現如 class
、 title
、 src
的 Property 。
屬性繫結 ( Property Binding ) 真正的對象,其實是 HTML 標籤下 DOM 的 Property ,而不是指 HTML 標籤上的 Attribute 。
套用在剛才的範例上就是
img
標籤,這一個 DOM 的 Property。
其實方法也不困難,只需要在前面補上 attr.
,如:
template
1 | <img [title]="title" [src]="imgUrl" [attr.data-title]="title" class="pull-left logo"> |
這樣子就可以成功設定 data Attribute 的自動綁定。
這時我們可以再次回到網頁上觀察情況。
可以發現 data-title 這個 Attribute 被綁定,而且也可以使用屬性繫結方法了。
屬性繫結的用法也是蠻直觀的,沒有太多困難的語法要記憶,只是要搞清楚 Attribute 與 Property 的差異,而就算忘記了也沒關係,畢竟開發者工具是這麼的好用,只要到 Properties 頁籤上就可以看到這一個 DOM 所有的 Property 了。
]]>接著介紹 Angular 中是如何進行資料繫結的,以及 Angular 內共有幾種資料繫結的方式呢?
在 Angular 內總共有四種資料繫結的方法,分別是
接下來我們將分成好幾篇逐步介紹這些繫結的方法。
之前把靜態網頁版型加到 Angular 專案內以 Component 方式管理後就沒有再動過了,這回我們從 app.component.ts
這裡開始。
app.component.ts
內容
1 | import { Component } from '@angular/core'; |
可以看到這個元件裡面除了一些必要的程式碼之外,沒有其他的東西了,相當的單純。而 class
裡面有一個 title
的屬性,這是一開始我們建立專案時 Angular CLI 自動幫我們添加的。
接著來到 app.component.html
,可將 <h1>
標籤處使用內嵌繫結 ( interpolation ) 的語法,替換成 class
內的 title
的屬性。
1 | <div class="pull-left"> |
打開 Angular 開發伺服器,可以觀察到 <h1>
標籤內容的確是 title
的屬性值。
內嵌繫結是屬於單向的繫結,也就是說畫面上呈現的 title
資料,只會從 Component 內把值傳送給 template 顯示。
內嵌繫結的語法除了放在標籤內,也可以使用在 HTML 標籤的屬性上。
舉個例子,我們在 app.component.ts
內的屬性上新增一個 link
屬性:
1 | import { Component } from '@angular/core'; |
接著回到 app.component.html
將某一段 a
標籤的 href
內容給替換掉:
1 | <div class="pull-left"> |
打開 Angular 開發伺服器,發現 a
標籤的 href
內容的確被替換了
不知不覺的快到五月的尾聲了,因受到洧杰老師邀請而擔任課程的遠端助教,用意是希望能幫助想轉職前端的學員們能夠用自己的知識賺回學費,並且能緩和轉職的陣痛期。因為我終於找到轉職前端後的第一份前端工作了,所以這個遠端助教的身分五月底就會交棒給之後的學員們,因此想藉由這篇把點點滴滴記錄下來。
好的,既然答應老師的邀約了,那麼具體來說要做些什麼呢?
於是我跟老師敲了個時間當面討論細節,順便拜訪六角學院,我也是從那次才知道六角人力真的是很精簡。
總結整理如下:
覺得自己可以當上助教蠻開心的,可以賺取學費又可以透過回答問題加深自己對 Vue 、 JavaScript 的掌握度,更重要的是可以培養溝通能力。
但對於如何擔任一個助教而言,我是沒有什麼頭緒的。
好在這部分也有考量到了,因為我主要負責 Vue 課程,所以我的 30 道問題是由該課程講者 - 卡斯伯老師 挑選。
回覆這 30 道問題的時候,必須同時記錄一些項目:
也因為需要紀錄花了多少時間回這道問題,所以我在做這些練習時反而因為緊張花掉很多時間,而且有些時候根據你的回答,還必須附上適當的文件佐證說明,所以整體而言是相當耗時的。
都完成後老師也會根據每一條問題給你回覆建議,並且視情況來安排。
這邊也附上我的練習截圖給各位參考
幸好實力有被老師肯定,做完這些題目後沒有太大問題就直接上工了。
在很久之前,我有想過一個問題。
我花錢就是希望直接得到老師的指點,可是回答的都是助教,這樣感覺很虧欸。
但是在我當上助教後才知道,代誌不是傻人想的那樣。
助教也不是想回學生什麼就任意回答的。
這個機制是為了保證助教的回答是正確且有一定品質,所以我們都會加入助教專屬的 Slack ,要回覆時需要把回覆內容貼到群組內,通過老師審核後才可以正式對外回覆。
具體大概像這個樣子:
如果審核通過就會有個讚,反之老師會在你的留言下方給回覆建議,修正後重跑一次流程。
一開始肯定是非常手忙腳亂,因為畢竟是收人錢替人做事,效率肯定要有。
在這個心態的前提下,精神異常的緊繃,所以基本上我如果安排早上 9 點上工,我就會在 08:40 開始看題目,大概有個底。
大多時候看到待回覆問題題數時,臉就黑一半了。
工作時螢幕切分大概是這樣的,沒有雙螢幕所以很彆扭。
左上是審核區;左下是回覆的內容;右上是問題敘述;右下是待命用的 VSCode
這個時期的我回覆問題的速度真的很慢,一題 20 ~ 30 分、甚至有一題花到 1 小時的,當天沮喪到不行,還偷偷跑去跟卡斯伯老師 QQ
結果後來就在直播上被公開處刑了(?
後來我仔細反思後,變更了回覆問題的方式:
經過了大概一個多月的努力,老師們認為我的回覆水平有達一定水準,有把握回覆的問題就不需要特地貼到 Slack 審核。
而每小時回覆的問題數量也漸趨穩定,至少不像一開始這麼悲劇。
處刑用對照圖
後來我又有了另一個體悟
有些時候回答問題的效率除了自己可能不夠熟 Vue 之外,很大的一部分是因為
遺憾的是,在我成為助教之前也是其中問爛問題的其中一人
也就是因為這樣,助教常常必須通靈才有辦法回答問題。
對我來說最好的問問題方式就是
強烈推薦老師之前線上問答會的影片發問的智慧
雖然口頭上是這麼說,但請不要誤會,助教群們都很歡迎學員問問題哩 :D
只是希望透過這篇稍微提一下…這應該也是其他助教的心聲吧?
要知道一個問題被描述的越精確,那麼這個問題就會越快被解答,省下你我的時間這是雙贏的局面。
雖然距離畢業還有快一個禮拜,但在擔任遠端助教的期間,我學到了
我要謝謝所有我在轉職路上幫助過我的人,雖然我目前還很不成熟,不過我會繼續在這個領域努力下去。
特別感謝六角學院的 廖洧杰 校長以及 王志誠 副校長,在我轉職的路上給的幫助特別多,也很感謝有這一次的機會擔任遠端助教。
最後也要謝謝 Slack 群內的 Jackson 助教以及葉子,在我不知道怎麼回覆時給予建議、幫我坦了不少條難回的問題。
希望我回的每個問題都有幫助到這些學員們!
]]>介紹完如何使用 Angular CLI 部屬 Angular 專案後,緊接著要提到如何更新 Angular CLI 或 Angular 的版本。但是這部分可能只有純介紹,因為我也是最近才剛開始開發 Angular ,所以環境基本上都是最新的,就不提供更新前後的對照圖了。
使用 VSCode 開啟專案並打開終端機,我們可以輸入 ng v
或是 ng --version
指令確認目前專案的 Angular CLI 的版本。
可知
我們可以透過 Angular CLI 提供的指令進行 Angular 專案的升級
1 | ng update |
透過這個指令可以把 Angular CLI 以及 Angular 甚至 TypeScript 、 Webpack 、 Rxjs 等等直接升級到最新版本。
所以我們不用擔心 Angular 要如何升級版本的問題,可以透過 ng update
輕鬆地把版本升級到最新版,而這個指令也會處理掉一些版本升級時可能會遇到的問題。
而背後的原理就是 Angular CLI 修改了 package.json 內記載的版本號,然後執行 npm update
命令。
當我們執行過 ng update
安裝完畢後,可以確認一次目前的版本,會發現到已經升級到最新了。
延伸讀物:
如果想更新全域的 Angular CLI 版本,可以打開命令提示字元,並輸入 npm list -g --depth=0
使用這個指令列出目前全域環境中,安裝了那些 npm 套件,並只顯示第一層目錄。
可以發現全域的 Angular CLI 版本同樣也是 7.3.9 版。
透過 npm outdate -g
指令查詢目前全域環境安裝的套件是否有最新版本
如果沒有任何資訊出現,代表目前已經是最新版。
如有出現訊息,則透過
npm install -g @angular/cli
重新安裝一次即可。
大部分的情況,如果是小幅度的升級,像是小數點後小版本的變更,基本上是不會動到程式碼的。
如果是大幅度的升級,像是 Angular 2 升級到 Angular 4 或者其他情況,可能就需要進行一些評估了。
而這部分 Angular 也很貼心的設有一個網站可以幫助我們快速查詢各個版本的 Angular 升級時需要注意的事項:
]]>現在我們 Angular 專案內的環境非常的單純,只有一個 Component ,而未來如果我們開發完成時,要如何透過 Angular CLI 發行與部屬 Angular 應用程式呢?
可以透過 Angular CLI 的指令辦到這件事情,叫出 VS Code 的終端機,並輸入以下指令:
1 | ng build |
這麼做 Angular CLI 會透過 webpack 幫我們把專案的內容通通打包並且輸出到 dist 資料夾內,而且是沒有進行 minify 的版本。
而如果是正式發布的產品版,則應該額外加入 --prod
參數,進行 minify 優化,如:
1 | ng build --prod |
首先是沒有加入 --prod
的結果
vendor.js 居然約有 3.3 MB ,真是驚人!
接著是有加入 --prod
的結果
從快 3.3 MB 的大小被壓縮到不到 200 KB ,而且剛才看到的 vendor.js 不見了,因為它跟 main.js 合併在一起了。
這次的實驗告訴我們,一定一定要記得部屬時幫這些 .js 檔好好的進行 minify 瘦身一番!
看看這個差距,的確是蠻誇張的…
]]>上一篇提到了如何把靜態資源加入到以 Angular CLI 建立的專案內,但這樣子是不夠的。我們希望能夠把這個網頁版型加入到 Angular 內,以 Component 的形式被管理,那該怎麼做呢?
打開 index.html ,整份網頁大致上可以拆分成 head 區塊以及 body 區塊。head 區塊是沒辦法拆成 Component 的,只有在 body 區塊才可以拆 Component 。
index.html
1 |
|
我用的網頁版型 HTML 部分還蠻多的,就不貼上來了。
也就是說:
了解該怎麼做之後,我們就動手吧!
這邊只有一點要注意,在 index.html 中有一個標籤很特別 <base>
。
base 標籤是用來控制網頁內的其他超連結,而這個標籤在這裡的用意是規定一個基準的 Url 路徑,也就是之後這張網頁內的其他超連結的基準都是從 / 開始找起。
所以我們把複製的內容放到 base 標籤下方,像這樣:
1 |
|
這部分因為 HTML 很多,所以就不貼上來了,將網頁版型中的 body 區塊貼到 AppComponent 的 HTML Template 內。
這邊有個小技巧,可以按下 Ctrl + E
透過搜尋的方式快速找到檔案,找到後就整個取代貼上吧。
執行 npm start
命令,觀察實際在瀏覽器上呈現的情形。
但畫面上空空如也,為什麼?
打開開發者工具發現一段錯誤:
在 Angular 中,只能使用 HTML5 規範的 HTML 標籤,而我使用的這個版型用到的這個 hgroup 標籤並不符合 HTML5 規範。
處理方式有很多種,其中最簡單的就是將其改成 div 標籤即可。
關於 hgroup 標籤的補充:
早期在 HTML5 規格尚未發佈正式版的時候,當時是有 hgroup 元素的,但 HTML5 工作小組在 2013/4/2 的一次會議結論中決定從規格中移除 hgroup 元素,因此請大家不要再用這個 hgroup 元素。
這樣就搞定了!
這樣我們就成功地把一份網頁版型加入到 Angular 內並且以 Component 的方式管理,所以可以到上一篇提到的 angular.json 檔案內把之前加入到 assets
內的 src/blog-index.html
給移除囉,因為已經用不到了。
現在有個狀況是,如果我們有一份靜態網頁的版型或者是一些靜態的圖片資源、 json 檔,那麼要如何地加入到 Angular 專案內呢?
首先我有一份靜態網頁的版型叫做 BlogSiteHtml ,解壓縮後的內容有:
接著把這些檔案全部貼到 Angular 專案的 src 資料夾內,並且輸入指令 npm start
重啟開發伺服器。
重啟開發伺服器後,我們可以在網址列輸入
1 | http://localhost:4200/blog-index.html |
會發現什麼事情都沒發生,畫面還是原本那樣。
為什麼?
因為當我們把這些新的靜態檔案加入到 src 目錄後,還需要設定 angular.json ,告訴這支檔案我們做了什麼異動。
進入到這支檔案後,可以看到非常多密密麻麻的設定,而我們這次要修改的是 assets
區塊的內容,而它位於 architect
物件下的 build
物件下的 options
物件內。
修改前
1 | "assets": [ |
修改的目的就是要告訴這支檔案我們在 src 資料夾內增加了什麼東西,希望它一起編譯。
修改如下:
1 | "assets": [ |
接著再度重啟開發伺服器,並且重新輸入對應網址觀察。
這次就成功地看到一個漂亮的部落格版型囉。
透過這樣的操作得知,如果未來開發時加入了一些新的靜態資源,除了將檔案複製進 src 外,也必須調整相關的設定檔,使其可以對應,這樣才能找到我們要的檔案哦。
]]>聽人家說 Angular CLI 相當的強大,可以幫助開發者在開發 Angular 時省下不少功夫。本篇要介紹 Angular CLI 是如何幫助我們快速建立元件與範本。
可以使用 VSCode 下方的終端機 (按下 Ctrl + ` 開啟),值得注意的是 Angular CLI Component 類型不只有一種,可以輸入以下指令查看它可以幫我們產生哪些 Component 範本:
1 | ng generate -h |
執行後可以得知它還能幫我們產生以下這些 Component 範本,是不是很方便呢?
如果要透過 Angular CLI 建立 Component ,並且把這個元件加到 AppComponent 下,以下指令擇一即可。
完整指令
1 | ng generate component myFirstCompoent |
簡寫指令
1 | ng g c myFirstCompoent |
執行後得到以下結果:
得知 Angular CLI 幫我們建立了 4 個檔案,並且更新了 app.module.ts 這支檔案。
並發現於 app 資料夾中多出了剛才輸入的 myFirstCompoent 資料夾 (不一致是因為 Angular CLI 會自動轉換成適合的名稱)
之後當我們使用這個方式來建立 Component 時,都會產生類似的檔案結構來建立 Angular 應用程式。
1 | import { Component, OnInit } from '@angular/core'; |
剛剛建立的 Component 的 selector 預設就叫做 app-my-first-component
,而 class
名稱就是 MyFirstComponentComponent
,第一個字母自動會變成大寫。
接著還有相對應的 HTML Template 與 SCSS , SCSS 的內容是空的 、 Template 的預設內容如下:
1 | <p> |
而透過 Angular CLI 還有一支 my-first-component.component.spec.ts 檔,這主要是拿來做單元測試用的檔案。
所以一個 Component 預設會有 4 支檔案被建立,還記得剛才 Angular CLI 有更新 app.module.ts ,讓我們看看它做了什麼。
1 | import { BrowserModule } from '@angular/platform-browser'; |
Angular CLI 很智慧地幫在 declarations
內註冊了剛才建立的 MyFirstComponentComponent
元件,而且也自動的把 MyFirstComponentComponent
元件給 import 進 app.module.ts ,相當的便利。
那麼,接下來我們要如何把剛剛建立的 MyFirstComponentComponent
呈現在畫面上呢?
如果我們直接運行開發伺服器,是看不見剛才建立的元件的。
如果要把 MyFirstComponentComponent
加入到 AppComponent
下,那麼就要把 MyFirstComponentComponent
的 directive 註冊到 AppComponent
的 HTML Template 內,像是這樣:
1 | <!--The content below is only a placeholder and can be replaced.--> |
而 VSCode 也很貼心的提供的自動完成的功能。
於是可以到瀏覽器觀察修改過後的結果:
接著可以把寫在
AppComponent
下的某幾段 HTML 全部搬到新建立的元件內
來到 MyFirstComponentComponent
的 HTML Template 進行如下修改:
1 | <h2>Here are some links to help you start: </h2> |
而 AppComponent
修改如下
1 | <!--The content below is only a placeholder and can be replaced.--> |
接著看看是否能順利運行!
Angular CLI 真的是蠻強大的,而且提供了許多便利的功能,而本篇僅介紹了其中一個元件類型的範本,而之後我也會建立其他的元件範本玩玩看。
]]>當我們透過 VSCode 的終端機執行 「npm start」 時,這段時間發生了什麼事情,讓我們好好地整理一下。
首先開啟一個之前就建立好的 Angular 專案範本,接著在 VSCode 的終端機執行 npm start
。
透過這張圖可以觀察到,當執行 npm start
時,其實是呼叫執行 Angular CLI 的命令 ng serve
,這個過程會啟動一個開發伺服器,而這個開發伺服器在啟動之前,背後透過 Webpack 將目前的 Source Code 內所有的 TypeScript 進行編譯。
編譯之後把所有的 JavaScript 檔案合併在一起,而這個過程中產生了幾支檔案:
那麼這些檔案會用在什麼地方呢?
在首頁打開的時候,預設會把剛才編譯產生的這些檔案給載入,我們可以透過觀察原始碼來了解。
首先我們看到一個
接著是
然後發現 之前被插入了剛剛編譯出來的 .js 檔,而 Angular 應用程式也在載入這些 .js 檔後正式開始運行。
然而執行的過程中,也有一個啟動的流程。
啟動的流程結束後,
從 chrome 的開發者工具底下,可以看到
因為這些內容全部都是透過 Angular 應用程式動態運算出來的結果。
這支檔案也就是剛才 chrome 瀏覽器開啟的檔案,而裡面確實有個
可以發現檔案內的
標籤內並沒有插入剛才那些 .js 檔,也就是說那是 Webpack 幫我們編譯後動態插入的。也就是說我們在開發 Angular 網頁時,它的 JavaScript 在開發時期是動態被注入的。
之前有提過, Angular 應用程式的進入點是 main.ts 檔案,而它的長相如下:
1 | import { enableProdMode } from '@angular/core'; |
前面 5 行主要是引用從某一些模組匯入程式運行時必要的物件進來。
而第 11 行的地方,則是透過 platformBrowserDynamic().bootstrapModule(AppModule)
去執行啟動模組這件事,接著會進入到 AppModule
裡面執行相關的程式碼,讓我們一起觀察下去吧。
小提示:在 VSCode 內可以對著
AppModule
點一下接著按 F12 會自動追蹤到該檔案喔
1 | import { BrowserModule } from '@angular/platform-browser'; |
這支檔案可以說是在 Angular 應用程式裡面最重要的一支程式,而程式碼結構不難看出它是一個
class
而且被 export 出來,神奇的是裡面沒有任何的程式碼。
在大部分的情況,我們寫 Angular 應用程式時確實是不需要寫程式在裡面的。
我們只需要套用一個 declarator
,而這個 declarator
要設定成 NgModule
,去宣告這個類別它是一個 Angular 的 Module ,然後我們在這個 Module 裡面又有好幾個 Property (屬性) 需要宣告:
BrowserModule
就是把 BrowserModule
內所有的元件一起匯入進來的意思AppComponent
,也就是 Angular 最上層的元件 (預設名稱為 AppComponent
)接著我們使用剛才介紹的追蹤方式,繼續追蹤 AppComponent
1 | import { Component } from '@angular/core'; |
AppComponent 元件的程式碼結構也一樣是個被 export 的 class
,然後這個範例程式預設有一個屬性 title
。
這個元件一樣有使用到 declarator
,叫做 Component
,這個 Component
在 Angular 內有特殊的涵義,這是用來宣告這個 class
代表的是一個 Component 。
而這個 Component 同樣有一些屬性如:
app-root
就是選取到 HTML 中 app-root
標籤,並且把這個標籤的內容,修改為這個元件執行的結果。.app-root
則是選取具有 .app-root
的 className舉例來說可以這麼做,我們修改 AppComponent 與 index.html 如下:
1 | import { Component } from '@angular/core'; |
1 | <body> |
之後回到瀏覽器觀察,發現仍然是可以運行的。
而 HTML 中不管是直接在元件寫上 className 或者是新建一個具有 app-root
className 的 div 標籤都是有效果的。
在我們把 selector
修改成 .app-root
後,下方突然多出綠色的蚯蚓,這是因為我使用了 TSLint ,而 TSLint 這個套件是遵照 Angular 官方發布的 Style Guide 規範,這當中就包含了 Component 的 selector 最好都以 element 的 selector 為主。
所以我們的每一個 Component 都會有一個相對應的 HTML Template 做搭配,一個負責程式的邏輯、另一個負責呈現於瀏覽器。
以上就是 Angular 應用程式的啟動流程,透過這樣的描述能讓自己更加地了解 Angular 的運作過程。
]]>Angular 採用模組化的元件開發,因此一個 Angular 專案裡面會看到非常多不同種類、大大小小的元件,而網頁畫面的構成也都是透過 JavaScript 切換這些大大小小元件渲染而成。可見元件在 Angular 中有多麼重要,所以我們要來理解 Angular 與元件的部分。
一個完整的 Angular 應用程式,它會包含一個模組,通常稱它為 AppModule ,而一個模組下會包含非常多的元件,如以下這張圖:
一個模組下可能會有:
元件的類型可能會有很多種,我們會透過一個模組把這些元件封裝起來。
因為一個完整的 Angular 應用程式至少會包含一個模組,也至少會有一個元件以上,因此我們可以說一個 Angular 應用程式就是由元件所組成的
今天我有一個頁面想要呈現在瀏覽器內,不外乎會有 HTML 、 CSS 、 JavaScript 這幾支檔案,最終瀏覽器呈現出完整的網頁。
而 Angular 的開發模式,全部都是元件化的開發模式跟以往我們使用 JQuery 的開發模式是不一樣的,我們不需要頻繁的操作 DOM 。在元件化的開發方式裡,我們鮮少會直接操作 DOM 元素,都是透過元件的切換來達成畫面的渲染。
可以想像成一個網頁載入後, HTML 內的 body 都是空白的,只有 JavaScript 。
所以頁面要如何渲染到瀏覽器上呢?
而這個過程是動態的透過 JavaScript 達成。
我們可以再次想像一個 Angular 頁面:
如圖,AppComponent 包覆著 HeaderConponent 、 AsideCompoent 、 ArticleCompoent 這些元件,也就是說 AppComponent 就是父元件,而被包覆在裡面的就稱為子元件,最後組合成一個完整的網頁。
然而實際上的網頁開發可能元件架構上沒有這麼單純,一個元件內可能又包覆一個子元件,而這個子元件可能又有另一個子元件,這種狀況是蠻常見的。像這樣一層包著一層的開發方式,就是所謂的元件化的開發方式。
而這樣的開發方式,會幾乎碰不到 DOM 元素的操作,我們只需要專注頁面邏輯以及商業邏輯的撰寫就可以了,而且商業邏輯會跟 view (template) 的部分是分離的,所以可維護性也會提升。
而這個部分就是 Angular 應用程式與元件之間的關係
]]>在 Angular 的世界中,並不是使用一般的 JavaScript 進行開發,而是使用 TypeScript 進行程式語言的撰寫。然而 TypeScript 與 JavaScript 又有什麼關係呢?
我們可以從一張圖了解 TypeScript 與 JavaScript 的關係:
從這張圖我們可以得知, TypeScript 是包覆著整個 JavaScript 的每個版本,換句話說, TypeScript 是 JavaScript 的超集合。
而要撰寫 TypeScript 的語言並不困難,我們仍然使用 JavaScript 來撰寫 TypeScript ,差別在於多了一些語言特性,但很多時候都還是仰賴我們在 JavaScript 建立起的觀念,因此擁有扎實的 JavaScript 基礎是相當重要的。
JavaScript 是動態型別的語言,也被稱為弱型別的程式語言,是在執行時期才真正擁有型別,而且會因為內容的不同而更改型別。相較於習慣靜態型別的程式語言(如 C#)的開發者來說,這樣是非常困惑的事情,因為沒有辦法很明確的知道某個變數是什麼型別。
而 TypeScript 的其中一個特性是,可以讓 JavaScript 直接宣告某個變數應該是什麼型別。
在前端領域中往往要考量到瀏覽器的相容性,舉例來說我們可能使用 ES6、7 的某個語法,但是在 IE8、IE9 卻沒辦法支援這些語法,這時候就必須安裝額外的套件像是 Babel 來處理這個情況,或者乾脆改用別的語法。
而 TypeScript 有 JavaScript 的編譯器,用來將 TypeScript 編譯成瀏覽器看得懂的 JavaScript ,而這個過程中 TypeScript 也會自動地幫我們把較高版本的 JavaScript 轉換成較低版本如 ES5 的語法,這是相當方便的。
到 TypeScript 的官方網站,有個 Playgrund,我們可以觀察 TypeScript 轉換成 JavaScript 後會是什麼樣子,這邊只是幫助我們了解 TypeScript 可以做些什麼,而一些 TypeScript 的語言特性等用到再說。
像這樣隨意地寫一個 ES6 的 class
1 | class greet { |
會被編譯成這樣
1 | var greet = /** @class */ (function () { |
]]>因此我們只需要專注於 TypeScript 即可,不需要理會編譯出來的 JavaScript ,只需要知道 TypeScript 會幫我們搞定相容性的問題。
新一代的 Angular 可以為我們做哪些事情,又相較於 AngularJS 而言有哪些特色呢?
跨平台
速度與效能
生產力提升
完整的開發體驗
效能改進 (Performance)
高生產力 (Productivity)
多平台 (Versatility)
熟悉的開發架構
更低的學習門檻
更好的執行效率與行動化體驗
更清晰的專案架構與可維護性
在還沒正式踏入開始寫 Angular 時,身為一個初心者自然會在網上查詢很多關於 Angular 的資料。這時肯定會注意到,怎麼好像有兩種不一樣的 Angular 圖案呢?而名稱似乎也有點不同,一個叫 AngularJS、另一個叫 Angular,兩者之間是不是有什麼關係呢?
Angular 最早是由 Google 團隊主導且全球領先的 JavaScript 應用程式框架,但在發展過程中都是由開源社群共同參與的。
那為什麼 Angular 有兩個名字呢?
因為早期在第一代的時候,它的名字也被稱為 Angular ,所以我們稱 Angular 1.X 版是第一代產品。第一代產品剛推出的時候得到非常大的迴響,有非常多的開發者使用這一套框架建置他們的網站,由於 Angular 框架的問世,造福了不少開發者。
Angular 1.X
但在發展了幾年之後發現有一點缺陷,後來 Angular 團隊花了兩年多的時間發展第二代的產品,我們稱它 Angular 2.X 。
Angular 2
從兩者的比較來看,第二代與第一代的產品有相當大的差別,從開發架構、使用的開發工具、甚至於有些部分開發觀念也都修改了。
換句話說,如果你使用的是第一代的 Angular 開發的網站,沒有辦法很快地升級成第二代的 Angular 。
這兩代產品差異這麼大但是名字又一樣,這對於大家討論 Angular 時造成很大的困擾。
因此大家就協議:
在我寫這篇文章的時候 Angular 已經出到 7 版了,從 2 到 7 之間的版本差異會不會很大呢?
差異肯定是有的,但不像從 AngularJS 到 Angular 這種層級的大翻新,因此從 Angular 2 之後的版本幾乎都可以很快速的升級成新版本,而 Angular 2 之後的版本我們也都統稱為 Angular 。
甚至 Angular CLI 有個命令是 ng update
,可以幫助我們把現有的專案升級成新版本的 Angular ,所以我們可以放心地使用 Angular 開發網站哦。
緊接著了解 Angular 專案內的檔案結構,我覺得這是相當重要的。畢竟只有在認識了這些檔案結構後,才有辦法因應日後專案的調整,也才知道要在哪裡修改對應的配置。
開啟 VS Code ,並且載入 Angular 專案,會看到旁邊一排的檔案結構。
angular.json 這隻檔案是 Angular CLI 的設定檔,所以這個檔案裡面可以看到有非常多的參數,未來我們也可能會到裡面調整一些參數,用到的時候再來了解就可以了。
.editorconfig 是一個非常常見的編輯器設定檔,這個檔案會告訴我們目前使用的編輯器怎麼處理 tab 符號、斷行符號等等。
詳細的設定可以到這裡了解。
.gitignore 是一個 git 專用的檔案,用來告訴 git 要忽略這個資料夾那些檔案不要加入版本控管。
karma 是一個單元測試工具,當我們需要做單元測試時可能會需要用到它。
目前專案 npm 的設定檔,這個檔案相當的重要,因為上面記載著這個專案用了哪些相依套件。
而其中的 scripts
區塊更是定義了未來在開發 Angular 時經常用到的命令,這邊也可以看到之前用過的命令。
同樣也是測試時使用的設定檔,這是 Angular 實作 End-To-End Testing 時會用到的設定檔,到時候用到再來研究就好了。
這個設定檔則是 TypeScript 的相關設定存放的地方,這個檔案通常也不太需要修改。
TSLint 是一套開源的 TypeScript 程式碼風格檢查器,白話點就是類似 ESLint ,這個設定檔就是用來設定 TSLint 所有檢查的規則。
這個資料夾非常肥大,主要內容為我們使用 npm install
後所有被下載下來所有的套件,基本上這個資料夾的內容都是透過 npm 來管理的,不太需要自己管理它。
跟先前提到的 protractor.conf.js 有關,是一個 End-To-End Testing 所有測試的指定檔都被放在這裡。而在 Angular ,所有 End-To-End Testing 的程式碼都是使用 TypeScript 撰寫,如果未來會用到再回過頭來寫這些檔案吧。
src 顧名思義就是整個 Angular 應用程式主要的原始碼,全部都放在這個資料夾底下。而這個資料夾的目錄結構完全按照 Angular 官網的 Style Guide 建立而成,所以我們只要按照這些規範來開發基本上不會出什麼大錯。
接著我們來看 src 底下的目錄結構
這個資料夾裡面就是應用程式的主要檔案。
畢竟 Angular 還是一個 web 的應用程式,既然是 web 就有 HTML,檔名預設就是 index.html。
同理,自然也會有 style.css ,所以當我們需要 CSS 定義時,可以修改這隻檔案,但是這支並不是單純的 CSS ,在這裡它是 「global styles」也就是整個應用程式都會套用到的 CSS 定義,全部都可以寫在這裡。
在 Angular 內,我們只能寫 TypeScript,所以這個 main.ts 就是 Angular 中 JavaScript 程式的進入點,也就是主程式。
1 | import { enableProdMode } from '@angular/core'; |
其中 platformBrowserDynamic().bootstrapModule(AppModule)
傳入了 AppModule
參數,而這個參數我們循著它的路徑找到位於 app 資料夾內的 app.module.ts 檔。
發現這隻檔案同樣的引用了其他的檔案:
1 | import { BrowserModule } from '@angular/platform-browser'; |
從這邊我們可以看出一些端倪,看起來 app.module.ts 像是在管理要載入那些元件。
而從程式碼看來又載入了 AppComponent
,於是順著追查找到了 app.component.ts 檔案,而這支檔案裡面記載著對應那些 template、style 的路徑,如:
1 | import { Component } from '@angular/core'; |
回到 app.module.ts ,這裡的 bootstrap
是啟動的意思,意思就是要啟動 AppComponent
。
也就是說 app.module.ts 指定了
AppComponent
當成整個 Angular 應用程式的根元件。
而目前我們看到的這些東西都是透過 Angular CLI 自動產生的範本,之後會一一的修改這些檔案。
最後還有一個 app.component.spec.ts 檔案,這是一個單元測試的定義檔,我們可以透過 component 附屬的 spec 檔來去定義要如何測試這個 component,所以當我們之後要寫單元測試時,我們就會需要修改這個檔案。
這個 assets 在英文上有資產的意思,通常意味著所有的靜態檔案,當 Angular 應用程式需要一些靜態檔案,如額外的 JavaScript、JQery、CSS、圖片等等,我們可以統一放置在這個資料夾下。
而這個資料夾底下的 .gitkeep 是給 Git 看的,因為我們在做 Git 版控時,如果有一個資料夾底下一個檔案都沒有,這個資料夾是不會進入版控的。因此這個 .gitkeep 檔是為了這個目的設計的,讓 assets 資料架內就算沒有任何檔案也能被加入 Git 版控。
這個資料夾裡面所定義的是 Angular 專案內的環境變數,而這裡面的檔案也都是由 TypeScript 寫成,也就是說我們會透過 TypeScript 定義一些環境變數。說白一點就只是個物件,我們可以在裡面新增一些額外的屬性。
這個資料夾內有兩個檔案,分別是 environment.ts 與 environment.prod.ts ,差別在於 environment.prod.ts 是只有當 build 出 production 版的應用程式時才用得上,否則在開發時期都是使用另一個設定檔。
polyfills 代表的是,當 Angular 執行時,如果使用者使用相對較舊的瀏覽器像是萬惡的 IE 系列,有些功能由於使用了一些全新的瀏覽器特性,所以有可能導致程式碼無法順利執行。而這個 polyfills 就是幫助 Angular 在這些比較舊的瀏覽器也能順利運行的關鍵。
這個檔是被前面介紹到的 Karma.conf.js 檔案使用,是拿來做單元測試才會用到的。
剛才我們講過一個類似的,不過那個是在根目錄下的 tsconfig.json ,而這個設定檔是繼承 tsconfig.json 並做出一些額外的定義。而這個檔案就是針對 app 資料夾裡面所有 TypeScript 來進行一些設定,比起最外層的設定,我們比較有可能修改到這一份,一樣是有用到再來修改即可。
是我們在寫單元測試的 TypeScript 程式碼的時候可能會需要用到的一些設定檔。
這個也是 TypeScript 會用到的設定檔,主要的用途是定義那些額外的 TypeScript 型別定義,比方說當我們想把 JQuery 也整合進 Angular ,我們就很有可能修改到這隻檔案,可以在這隻檔案內宣告一些 Angular 內會用到的全域變數,例如 $ :
1 | declare var $: any; |
這麼做之後,所有 Angular 內的 TypeScript 程式碼都可以使用 $ 這個全域變數,於是在編譯時就不會出錯了,而這部分也是需要用到時才要修改的。
不過這支檔案,我的 Angular CLI 版本是 7.3.9 版,似乎沒有自動產生這隻檔案,因此這隻檔案很可能之後要自己手動增加或是已經捨棄不用了。
總算是介紹完 Angular 的檔案結構了,除了 TypeScript 相關的檔案之外,其他都是蠻眼熟的檔案,然後也明白 Angular 也是元件、元件、元件堆起來的網站,這點跟 Vue 蠻像的,目前比較在意 TypeScript 的部分,設定檔這麼多支感覺起來頗複雜,再加上不熟 Angular 語法,可以預期是場硬仗。
]]>安裝好開發 Angular 需要的環境後,緊接著就是建立起 Angular 專案的骨架了,讓我們一塊來了解如何使用 Angular CLI 建立一個 Angular 的專案吧。
那麼,該如何 Angular 專案資料夾呢?
首先,我們可以先手動替專案建立一個資料夾,像是我在 D 槽建立一個 learnAngular 的資料夾,這樣比較方便管理。
接著打開 CMD 並且輸入以下指令,切換到剛才建立的資料夾內。
1 | cd /d D:learnAngular |
ng new 專案名稱
- 透過指令建立 Angular 專案1 | ng new firstAngular |
輸入後, Angular CLI 會問我們一些事項:
接著進入一連串的套件安裝,安裝完後可以發現多了 firstAngular 資料夾,且裡面建立了不少檔案。
然後再次使用 cd 指令,進入 firstAngular 資料夾。
1 | cd firstAngular |
正確切換到這個資料夾後,輸入 npm start
運行 Angular 專案。
如操作正確,應看到如下畫面,可複製網址至瀏覽器網址列貼上觀察。
注意:此時 CMD 視窗不可關閉,因為執行
npm start
指令後,其實是透過ng serve
的指令啟動 node.js 的 web server ,這樣我們才可以在瀏覽器上看到對應的網頁。
進行到這個階段,目前我們已經掌握了:
npm start
運行 Angular 的開發伺服器,並透過瀏覽器觀察這個網頁接下來我們要了解 Angular 專案內的檔案結構。
]]>本來是沒有打算要學習 Angular 的,目前前端的三大框架 Angular、React、Vue 我已經學習了 Vue ,想說應該是精通一種框架運用即可。但人算不如天算,在新的工作環境中需要掌握 Angular 的技術,當作給自己的挑戰,那就硬著頭皮上吧。
「工欲善其事,必先利其器」,下面這些軟體可以單獨到各自官網安裝,也可以透過 Chocolatey 一鍵安裝,總之讓我們先準備一下環境吧。
Chocolatey 是一個 Windows 下的軟體包管理器,可以像在類 Unix 系統中使用 Yum 和 APT 一樣使用它,在 Windows 中實現自動化輕鬆管理 Windows 軟體的所有方面(安裝,組態,更新和解除安裝)
首先進到 Chocolatey 網站內,可以看到 install 的按鈕。
接著往下捲一點,這邊有兩個選擇
這裡我使用 CMD 安裝,以「管理員身分」打開 CMD 後貼上這一串
1 | @"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -InputFormat None -ExecutionPolicy Bypass -Command "iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))" && SET "PATH=%PATH%;%ALLUSERSPROFILE%\chocolatey\bin" |
執行結束後,輸入 choco /?
確認版本號,若安裝成功則出現對應版本號,出現錯誤則代表沒有安裝成功。
我們可以透過 Chocolatey 官網提共的搜尋功能,快速找到我們要安裝的軟體,例如搜尋 node.js 。
由於我們要安裝 Git、VSCode、Node.js ,所以打開 CMD 並且輸入以下
1 | choco install -y git nodejs vscode |
參數說明
-y
- 安裝時默認同意,不再詢問是否同意安裝透過這樣的方式,我們僅需要一行指令,就安裝好這些軟體,這是相當方便的,當然 Chocolatey 並非必要的,我們還是可以手動進行軟體的安裝。
如果是使用 Chocolatey 方式安裝,必須先把當前的 CMD 關閉重啟,才可以正確執行指令。
依序輸入
npm -v
、node -v
、git --version
,若都有安裝成功則回應版本號。
最後打開 VS Code ,就可以開始我們的第一個專案囉。
接下來我們要安裝 Angular CLI ,看到這邊覺得蠻熟悉的, Vue 也有 CLI。
同樣是透過 npm 來安裝,打開 CMD 輸入指令如下:
1 | npm install -g @angular/cli |
輸入
ng --version
檢測是否正確安裝
這部分就屬於非必需的了,這部分可以自由挑選喜歡的擴充插件讓開發 Angular 時更加得心應手。所以這裡推薦保哥的 Angular Extension Pack ,下載次數有 26 萬,評價也蠻高的,看來是個不錯的插件。觀察相依性的部分,也安裝了一些開發 Angular 時會用到的額外插件,算是安裝一套,就全部搞定的感覺。
以上就是開發 Angular 時可能會用到的所有工具,接下來我們就可以著手建立 Angular 專案囉。
]]>重提一下 Lidemy HTTP Challenge
是 Huli 在程式導師計畫中推出來讓我們練習的小遊戲,用來加深對於 HTTP 通訊協定的觀念。
在 HTTP_Game攻略(一) 中,我們完成了前面 10 關的挑戰,但是前面 10 關只是開胃菜,繼續往前邁進吧!
休息關卡,前往下一關的網址為
1 | https://lidemy-http-challenge.herokuapp.com/lv11?token={IhateCORS} |
新的挑戰就要搭配新的 API 文件
利用此 API 文件內提供的方法,跟伺服器打個招呼。
這一題如果直接使用 GET 方法,會得到這段系統提示:
您的 origin 不被允許存取此資源,請確認您是從 lidemy.com 送出 request
因此,我們需要在 header
內偽造 origin
,具體作法如下
1 | var request = require('request'); |
執行後得知
token
為{r3d1r3c7}
使用 API 文件提供的方法,獲得藏在其中的 token
這一關不需要寫程式碼,要考驗的是會不會使用 chrome 開發者工具,打開瀏覽器後,按下 F12 ,然後在 Network 分頁中預備觀察 request ,並於網址列中輸入以下網址,訪問 API
1 | https://lidemy-http-challenge.herokuapp.com/api/v3/deliver_token |
會發現當訪問該 API 後,我們被轉址到其他地方了。
順序如下:
deliver_token > stopover > deliver_token_result
因此,中間的 stopover 就是關鍵!
可以透過 Network 分頁觀察 stopover ,發現 header 內夾帶 X-Lv13-Token: {qspyz}
。
這部份比較難敘述,因此直接引用題目內文:
太好了!自從你上次把運送用的 token 拿回來以後,我們就密切地與菲律賓在交換書籍
可是最近碰到了一些小問題,不知道為什麼有時候會傳送失敗
我跟他們反映過後,他們叫我們自己去拿 log 來看,你可以幫我去看看嗎?
從系統日誌裡面應該可以找到一些端倪。
如果直接使用 GET 方法,會得到這段系統提示:
此 request 不是來自菲律賓,禁止存取系統資訊。
沒什麼頭緒,代入 hint=1
看看提示吧:
你有聽過代理伺服器 proxy 嗎?
看來是要我們設定跟 proxy 有關的東西了。
我們可在 chrome 的設定頁面中搜尋 proxy ,即可開啟設定視窗
接著就是上網搜尋 proxy 的站點了:
勾選協議是 HTTP 按下搜尋,並挑選可用性高且速度快的設定上去吧。
這個步驟可能要重複多次,畢竟不是每個 proxy 都能用。
接著在網址列輸入以下,訪問該 API
1 | https://lidemy-http-challenge.herokuapp.com/api/v3/logs |
成功可以看到以下畫面,得知
token
為{SEOisHard}
同樣是很難敘述的一關,直接上文章敘述:
跟那邊的溝通差不多都搞定了,真是太謝謝你了,關於這方面沒什麼問題了!
不過我老大昨天給了我一個任務,他希望我去研究那邊的首頁內容到底是怎麼做的
為什麼用 Google 一搜尋關鍵字就可以排在第一頁,真是太不合理了
他們的網站明明就什麼都沒有,怎麼會排在那麼前面?
難道說他們偷偷動了一些手腳?讓 Google 搜尋引擎看到的內容跟我們看到的不一樣?
算了,還是不要瞎猜好了,你幫我們研究一下吧!
依然沒什麼頭緒,代入 hint=1
看看提示吧:
伺服器是怎麼辨識是不是 Google 搜尋引擎的?仔細想想之前我們怎麼偽裝自己是 IE6 的。
我們之前是透過修改 User-Agent
假裝自己是 IE6 ,所以這一題也是要從 User-Agent
著手。
但在這之前,我們需要先了解,所謂的 User-Agent
是什麼:
在還沒接觸這個挑戰時,我一直以為 User-Agent
就是設定一串透過哪種瀏覽器名稱、瀏覽器版本號的字串,但解完這一題後,發現 User-Agent
其實還可以設定要使用哪種搜尋引擎。
因此這題的答案也呼之欲出了,只需要修改之前偽裝 IE6 瀏覽器那一題的程式碼即可。
1 | var request = require('request'); |
執行後得知
token
為{ILOVELIdemy!!!}
恭喜破關~謝謝你跟著本文一路前進到這裡。
如果覺得這個遊戲好玩,也請多多支持遊戲的開發者。
另外我也把這幾關有寫到程式的部份,放到 Github 中,有需要可以自行下載研究哩 :D
]]>Lidemy HTTP Challenge
是 Huli 在程式導師計畫中推出來讓我們練習的小遊戲,用來加深對於 HTTP 通訊協定的觀念。在第三期還沒正式開始之前,我就已經先玩過一次了,不過當時是使用 POSTMAN 通關,這次我打算使用 Node.js 搭配 Request 套件通關。
只是教學關卡,按照說明文建操作即可。
參數部份
token={...}
,當成功解決關卡就會得知 token 內容,代入即可前往下關。&hint=1
,看提示用因此輸入下列網址,前往下一關。
1 | https://lidemy-http-challenge.herokuapp.com/lv1?token={GOGOGO} |
使用 get 方法把自己的 namr 傳給 Server 。
操作網址列帶入參數即可。
1 | https://lidemy-http-challenge.herokuapp.com/lv1?token={GOGOGO}&name=Alvan |
得知
token
為{HellOWOrld}
有本書的 id 是兩位數,介於 54 ~ 58 之間,找到是哪一本之後,把書的 id 傳給 Server 。
操作網址列帶入參數 (id: 54 ~ 58 間) 即可。
1 | https://lidemy-http-challenge.herokuapp.com/lv2?token={HellOWOrld}&id=56 |
用硬 A 法得知
token
為{5566NO1}
查看 LV.1 時得到的API 文件
新增一本書名是《大腦喜歡這樣學》,ISBN 為 9789863594475 ,接著把 id 傳給 Server
1 | request.post({ |
執行後獲得 id
為 1989 ,從網址列傳給 Server。
1 | https://lidemy-http-challenge.herokuapp.com/lv3?token={5566NO1}&id=1989 |
得知
token
為{LEarnHOWtoLeArn}
之後的關卡大多都需要查看 API 文件,就不贅述了。
搜尋書名有:「世界」兩字,而且是村上春樹寫的,接著把 id 傳給 Server
直接代入參數 q
查詢是無效的,本題關鍵點在於使用 encodeURI()
轉換網址。
1 | let str = '世界'; |
執行後獲得 id
為 79 ,從網址列傳給 Server。
1 | https://lidemy-http-challenge.herokuapp.com/lv4?token={LEarnHOWtoLeArn}&id=79 |
得知
token
為{HarukiMurakami}
刪除一本 id 是 23 的書
使用 delete 方法,得到系統回傳的 token
1 | request.delete('https://lidemy-http-challenge.herokuapp.com/api/books/23', function (error, response, body) { |
得知
token
為{CHICKENCUTLET}
獲得新的 API 文件,往後都使用這一份。
獲得一組帳號密碼:
由文件可知,必須準備好一組字串,內容為 base64(username:password)
。
所以要對帳號以及密碼進行 base64 編碼, Node.js 可使用 Buffer.from()
進行 base64 編碼。
得到 base64 編碼後,將其加入請求的 header 中。
http basic authorization
1 | var request = require('request'); |
執行後得知 email
為 lib@lidemy.com ,使用 query string
傳給 Server 。
得知
token
為{SECurityIsImPORTant}
刪除 id 是 89 的書籍
與上題差別不大,修改方法以及 API 即可
1 | var request = require('request'); |
執行後得知
token
為{HsifnAerok}
修改某書內名稱有個「我」且作者的名字是四個字, 輸入錯的 ISBN 最後一碼為 7 ,只要把最後一碼改成 3 就行了。
1 | var request = require('request'); |
執行後得知
token
為{NeuN}
根據敘述,需要符合兩個條件才能使用這個 API
X-Library-Number
的 header,我們圖書館的編號是 20User-Agent
1 | var request = require('request'); |
執行後得知 version
值為 1A4938Jl7 , 使用 query string
傳給 Server 。
1 | https://lidemy-http-challenge.herokuapp.com/lv9?token={NeuN}&version=1A4938Jl7 |
執行後得知
token
為{duZDsG3tvoA}
猜數字遊戲,規則如下:
出題者會出一個四位數不重複的數字,例如說 9487。
你如果猜 9876,我會跟你說 1A2B, 1A 代表 9 位置對數字也對, 2B 代表 8 跟 7 你猜對了但位置錯了。
把要猜的數字放在 query string
用 num 當作 key 傳給 Server 。
就慢慢嘗試,這是邏輯問題
1 | https://lidemy-http-challenge.herokuapp.com/lv10?token={duZDsG3tvoA}&num=9876 |
最後得知正確數字為 9613
執行後得知
token
為{IhateCORS}
到這邊基礎的 1 ~ 10 關已經全破了,然而後面還有 5 關比較進階的關卡可以挑戰,寫到這邊篇幅已經很長了,下一篇再繼續寫攻略。
]]>因緣際會下參加了這個計劃,開始不間斷的自主學習,這是我的計劃 repo ,有興趣的朋友可以點開來看看,每周我提交的作業也都會在上面。不過之後要是找到工作,可能就沒辦法兼顧這邊了,但我還是會想辦法完成它。
這一周算是比較放鬆的週,主要就是拿來回顧、寫寫心得。
前面幾周因為自己並不是毫無程式基礎的人,所以就算因為被其他的事情耽擱到一些進度也不要緊,算是還可以處理的範圍,因此前面幾周並沒有感受到特別的壓力。
我也覺得自己真的很幸運,幾個月前我從來沒想到自己會走到這裡、參加這個第三期的實驗計劃,也不清楚自己是哪一點特質願意讓胡立免費讓我參加這一次的計劃,我只想說「謝謝」。
在轉職前端的路上,我有太多的人要感謝了,大家都很熱情的幫助我,在我做的到的範圍裡,我也會持續的分享自己學習的紀錄。
因為自己是本科生的關係,所以計算機概論的知識算是有的,並沒有耗費太多時間在課程上,但我還是很認真的全部看完了。也算是複習了已經還給老師的排序法。
Git 的部份雖然我之前已經有先學習過了,但我覺得多聽幾次有益無害,而且也多認識到如何發一個 PR 給別人,這還蠻有趣的。也讓我知道從開分支起到一系列的操作過程、發 PR 給別人 Merge ,這樣子的過程就是所謂的 GitHub flow ,算是又多了解一個名詞。
Command Line 的部份我也很喜歡,就覺得工程師就是要用 Command Line 輸入指令才帥啊,雖然比較不直覺是真的,需要一點時間適應。
這周開始進入到 基礎 JavaScript 的部份了,因為之前有學習過的關係,也沒有花太多時間在理解課程上,同樣是全部看過一次就開始練習寫題目了。透過這樣的題目練習,我明白果然學程式不能只用看的,還是要實際寫才能知道水深。
而且,把大問題逐步分解成一個個小問題的心法很受用,覺得這一周最重要的莫過於這個心法了。
第三周是上一周的進階,而這一周的課程就是我比較欠缺的了,如:
等等一些較進階的東西,所以我也在自己的部落格寫了 8 篇跟 Webpack 相關的應用:
花了不少時間,但我覺得很值得。用自己會一點點的技術去探索、到完成一個好像可以動的東西,這樣的成就感是非常巨大的。而這一周的作業也是相當的有挑戰性,我第一次寫大數加法,不過幸好沒遇到太大的瓶頸,因為提示蠻多的。
這一周開始提到網路原理,不得不說我覺得這個章節比喻得很好,最驚訝的是居然有發大財的梗,還以為這是出很久的課程了。
而很巧的是,在這周之前我已經不知不覺地提早預習好了,原因是我有花 280 去買付費的講座,剛好有提到這些觀念。因此在本周的時候特別的得心應手,聽胡立講賣便當的故事時,也可以知道那些比喻的背後是在說些什麼,也更佳的佩服 Huli 怎麼可以把比喻成這樣 (稱讚意味)
這周的作業我最喜歡的莫過於串接 Twitch API 了,我本來就是一個很喜歡看實況、打電動的宅宅,覺得可以串接這些 API ,撈出這些遊戲、頻道的資料感到相當開心,亦從中學習了不少東西,像是如何在 header 內加入對方要求的東西,使得請求可以通過驗證。
]]>總算是來到這個系列最後一篇了,前面雖然有提到如何使用 Webpack 打包 SCSS ,但如果 SCSS 內的語法有使用到圖片相關資源的話,可是會編譯失敗的,因為這部份又需要另外的 loader 處理,讓我們趕快來看看吧。
1 | npm install file-loader --save-dev |
rules
1 | { |
file-loader
有一些 options
可以設定,如果沒有特別設定圖檔名會是 hash 值,因此 options
內的設定分別為:
name
: [name]
為使用原本檔案的名稱; [ext]
則是副檔名,組合起來的意思就是,我們希望可以保留原本檔案的名稱以及副檔名publicPath
:設定目標文件的路徑,白話說就是 SCSS 編譯後產生的 CSS 檔內圖檔的路徑emitFile
:默認為 true
,如果為 true
,則將該檔案輸出。如果為 false
,則僅在 CSS 內寫入 publicPath
,而不會輸出該檔案。另外還有很多設定可以調整,詳見此
設定完之後 webpack.config 如下:
npm run dev
到此,開發環境的設定成功!但還沒結束,我們也必須對 webpack.prod.conf 進行特別的調整才行。
加入一組跟 webpack.config 一樣的 rules
,但在此我們必須做一些微調:
1 | { |
outputPath
:檔案輸出的路徑npm run build
確認圖片輸出沒問題後,我們還要額外加上圖片壓縮的功能。
要部屬出去的圖片是需要進行壓縮的,否則圖片動輒幾 MB 哪受的了。
1 | npm install image-webpack-loader --save-dev |
比較不同的是,這個 loader 的執行必須在 file-loader
之前,畢竟要先壓縮後打包。因此修改配置如下:
1 | { |
npm run build
壓縮成功
至此,設定全部成功,享受這份成果吧!
終於寫完 Webpack 系列了,這一系列並不是很深入研究 Webpack 每個細節的類型,我寫這些文章只是為了記錄自己如何把 Webpack 應用在實務上,並且逐步的探索有哪邊需要學習。
所以這一系列的起點從「如何模組化 JavaScript」開始,模組化之後我可以做什麼?模組化之後我可以進行單元測試,所以寫了「如何進行使用 Jest 進行單元測試」等等,諸如此類的小問題,最後完成這一整個系列。
當然 Webpack 還有很多很多我沒寫到而且很實用的東西,像是可以使用 Webpack merge 讓每個設定檔不會這麼又臭又長。
這部份推薦六角分享團內另外一個大大 慢慢變強的工程獅 寫的 Webpack 筆記,目前連載到第 15 篇了,還不趕快追起來 ~
我在寫這系列的文章時,有不少也是參考他的文章,真的對我幫助很大!
也深刻的體會到,學習東西時,如果有一份淺白易懂的文件可以參考,是多麼幸福的事情!
因此這可能不是一份高手向的 Webpack 文章,敘述用語、技巧可能不是非常正確,還請前輩們不吝指導分享。
如果能夠幫助到跟我程度差不多的朋友,那就太好了。
以下附上文章連結 (按順序)
GitHub
]]>在我們知道如何調整 SCSS 並且成功打包輸出後,接著要嘗試打包 HTML 檔。
我們之前都是手動把 index.html 檔案加入到 dist 資料夾內,並且引用 CSS 與 JavaScript 檔案,現在我們要嘗試把 index.html 也一起打包。
1 | npm install html-webpack-plugin --save-dev |
同樣的在檔案開頭 require
進來,並且在 plugins
區塊加入設定:
1 | const HtmlWebpackPlugin = require('html-webpack-plugin'); |
title
- 輸出的 title 名稱filename
- 輸出檔名template
- 套用的模版這個輸出的檔案預設會引入 Webpack 打包之後的所有檔案(像是 .css 、 .js ),若是想要輸出多頁,則需要設定多個 HtmlWebpackPlugin
物件。
接著還需要調整一下路徑, HtmlWebpackPlugin
打包後產生的 HTML 內,這兩隻檔案的路徑就是根據對應的 filename
上的路徑怎麼寫的。
做完這個步驟後,整體看起來會像是這樣:
接著指定樣版為我們目前使用的 index.html ,然後把原先我們手動設定好的路徑全部都移除,不然會重覆。
1 | npm run build |
雖然設定好了,但有個地方的問題沒有解決,列出問題點如下:
HtmlWebpackPlugin
設定,並同樣使用 index.html 作為模版,也會陷入死胡同。目前我有想到一個方式,但可能不是最好的辦法,僅供參考。
把要當成樣版的 index.html 搬到 src 資料夾內,這樣就可以使用相對路徑 ./all.min.css
、 ./all.min.js
引入對應檔案。
調整後的 webpack.config 如下:
HtmlWebpackPlugin
追加設定:1 | plugins: [ |
透過這樣的調整,讓開發環境與部屬打包後的路徑一致,就可以在開發時先把路徑寫好,只透過 HtmlWebpackPlugin
幫我們產生其他的部分。
1 | npm run build |
]]>這樣子就搞定了,雖然不確定這是不是最好的做法,但這是我目前想到的解法,就參考看看吧。
接著我們要讓 Webpack 也支援 SCSS ,輸入指令後轉成 CSS 並且優化之後打包輸出,同樣地延續上一篇的專案資料夾。
dev
指令時會輸出再 src 內,並且以 all.min.css 的檔名出現。最基本的 Webpack 功能其實只有 Javascript 部份,因此若是希望 Webpack 也能幫我們做更多事情的話,就必須要仰賴相對應的 loader 與 plugins 。
css-loader
: 載入 .css 的檔案mini-css-extract-plugin
: 將 CSS 輸出成檔案sass-loader
: 載入 .scss 的檔案node-sass
: Sass 的編譯器順序大致上是這樣的:
sass-loader 載入 .scss 檔 > 編譯成 css 檔 > css-loader 載入 .css 檔 > 最後則是給 mini-css-extract-plugin 打包成檔案。
全部串在一起下載回來吧!
由於上一篇提到的問題,所以我們將設定拆成了開發用以及部屬用的兩隻設定檔。
接下來我們要在開發用設定檔裡面增加 loader
設定,在物件 module
底下會有一個 rules
的陣列,在這個陣列裡面放置的每一個物件,是當 Webpack 無法辨識目前要載入的資源檔時,會到這邊去查找看看有沒有相對應的載入方式,在這個物件裡面會有兩個屬性:
test
: 是一個正規表示式,主要是去查找目前要載入的檔案,有沒有跟這個正規表示式符合use
: 表示我們載入檔案要使用的 loader
值得注意的是, Webpack 調用
loader
的順序是從後面到前面
把 mini-css-extract-plugin
給 require
進來
1 | const MiniCssExtractPlugin = require("mini-css-extract-plugin"); |
use
陣列內依序填入 MiniCssExtractPlugin.loader
、css-loader
、 sass-loader
test
正則表達式的部份也要修改成 test: /\.(scss|sass)$/,
module
區塊下方補上 plugins
區塊1 | plugins: [ |
這一段主要是使用 MiniCssExtractPlugin
指定 .css 檔案該輸出到哪裡。
做完這些調整後,會長的像這樣:
別忘了我們的進入點是 all.js ,需要把 .scss 檔案給載入才會一起打包哦。
做到這邊先測試看看吧~
npm run dev
網頁的部份也沒有問題哩!
而且因為使用了
--watch
,每當檔案有異動、按下存檔時都會再度編譯,是不是跟 VS CODE 的插件 Live Sass Compiler 用起來體感相似呢?
所以我們將剛才設定的內容都複製一份到這裡,我們會覺得有很多重複的東西感覺很不智慧,事實上這可以利用 Webpack-merge 來處理重複的部份,但這不在這次的討論範圍內,為了控制篇幅,就不提了。
別忘了輸出路徑也要調整一下,才會輸出到正確的地方唷。
雖然結果是正確的,但不夠理想。
畢竟這是正式部屬的設定檔,我們需要針對 .css 做壓縮才行。
optimize-css-assets-webpack-plugin
這個套件將幫助我們把 CSS 優化,馬上輸入指令下載回來
1 | npm install optimize-css-assets-webpack-plugin --save-dev |
optimize-css-assets-webpack-plugin
載入module
下方額外加入 optimization 物件,如:1 | optimization: { |
看起來完成了,測試看看吧!
成功的壓縮了,但…事情沒這麼簡單,我們的原先由 Webpack 自動幫我們壓縮好的 all.min.js 失去壓縮了。
因為 Webpack 預設是當我們使用部屬模式時,會自動的幫我們壓縮 .js 檔案,但是當我們自行加入 optimization
區塊時,它就會認為我們要自己管理,也就是說我們現在必須額外加入壓縮 JavaScript 的套件。
terser-webpack-plugin
1 | npm install terser-webpack-plugin --save-dev |
如同加入 CSS 壓縮功能的步驟:
terser-webpack-plugin
require 進檔案1 | optimization: { |
再次測試看看吧!
至此,設定全部都完成了呢!
現在瀏覽器這麼多種,為了相容各式各樣的瀏覽器,有些時候我們得在 CSS 語法前手動補上一些前綴詞,才能在相應的瀏覽器內生效,但是這樣子不夠智慧。
聰明的工程師想出了一套解決辦法:
透過這兩個工具,自動的為我們補上前綴詞
事不宜遲,馬上下載安裝
1 | npm install postcss-loader autoprefixer --save-dev |
我們知道在 Webpack 內 loader 載入的順序是有差別的,因此在加入這些套件後,順序應該調整為:
sass-loader
編譯postcss-loader
加上前綴詞css-loader
處理MiniCssExtractPlugin.loader
打包輸出得知順序後,開始著手調整設定吧。
調整後的設定檔如下:
成功了!可喜可賀!別忘了把部屬用的設定檔也補上哦。
透過這樣一連串的設定,對於如何替 Webpack 加裝一些套件有更深刻的體驗,幸好這樣的設定只要做一次,後續就可以直接沿用,否則每次要做這麼多的設定也是很麻煩的。
]]>延續之前的專案資料夾,我們目前不僅能模組化管理 JavaScript 、使用 Jest 進行單元測試、針對 JavaScript 語法使用 Babel 進行降版、還能夠自訂一些看起來很酷的指令,最後打包輸出 bundle.js 在 dist 資料夾中。
但似乎有哪裡不對勁,不是嗎?
npm run dev
,打包出來的 .js 檔案居然放在 dist 資料夾內,而不是放在 src 資料夾內。這怎麼想都很奇怪,這樣肯定是不行的!
還記得之前是如何自定指令的嗎? scripts
內的指令可以透過設置參數的方式,套用客製的 webpack.config 設定:
--config [config 檔案路徑]
:可指定要套用的 config 檔案路徑,使指令套用此設定檔我們將原本那一份 webpack.config 當成開發時期用,接著建立一個新的資料夾 build ,並把原本那一份設定檔另存改名成 webpack.prod.conf 。
如果檔名設定成 webpack.config 的話, Webpack 預設會認這個檔名,因此使用 dev
指令時不需要額外指定,而 build 指令我們希望套用別的設定檔,因此需要加上 --config
參數
調整了 path 路徑,使其不產生資料夾,並指定檔案輸出路徑。
npm run dev
,看看檔案是否打包輸出再預期的位置npm run build
,測試看看大功告成~距離完善整個開發環境又更靠近一步了。
這麼一來,我們的開發環境又更完整了一些,但這樣子還不夠!
預期還要加入自動壓縮打包 SCSS 、圖片、網頁,這樣才能更貼近實務在開發網頁時的需求,因此這系列後面幾篇就會著墨在如何設定這些東西哩。要設定的東西真的蠻多的,幸好只要做好一次環境後,之後都可以用複製的。
寫這一系列的時候, slack 內也有一位大大跟我寫類似的東西,他目前專注於 Webpack 上,寫得相當仔細,目前已經完成高達 14 篇關於 Webpack ,內容與各參數的說明是相當的詳盡,做為自己文章的補充,將連結寫在這:
而我的這一系列文章的目標,主要是圍繞在「如何使用 Webpack 手把手搭起一個開發專案」,然而有些東西我也尚在探索,因此看到有相關文章可以參考是非常令人感到開心的!
]]>當我們使用的 JavaScript 語法瀏覽器不支援時,大家想到的第一個解決方案可能就是 Babel ,那麼說歸說,到底要怎麼跟先前提到的 Webpack 做結合,實際運用在開發上呢?讓我們繼續看下去~
如果不知道這些是什麼,可參考
現在有一支 all.js 的檔案為主要進入點,因為我們模組化管理 JavaScript 檔案的關係,所以在這支檔案內 import
了一些寫好的 JavaScript 檔案。
但這些 JavaScript 檔案有些是使用 ES6 以上的語法完成,因此可能在較低版本的瀏覽器會有不支援的情況發生,因此希望結合 Babel 把 ES6 以上的語法轉換成比較舊版本的寫法,並且透過 Webpack 打包輸出至 build 資料夾。
為了達成這個需求,我們要下載以下的檔案
@babel/core
:程式需要調用 Babel 的 API 進行編譯@babel/preset-env
:告訴 Babel 幫我們轉換成什麼版本的 JS 語法babel-loader
:Webpack 中要去讀取任何檔案格式都需要靠 loader 這個工具去做判讀,接著去做轉換。因此我們可以利用 npm 把語法通通組起來:
--save
:代表希望把安裝的資訊寫到 package.json 依賴內,這個部份在比較高版本的 npm 已經是預設值了,但我還是習慣加上。-dev
:有特別寫上的話,代表這是開發時才會用到的依賴項目若是成功安裝,package.json 內會長得像這樣:
接著要在 package.json 內的 script
內自訂一些指令方便使用:
test
:是之前使用 Jest 進行測試的指令,與本次無關build
:當專案完成時,使用這個指令將專案打包發布dev
:開發時使用的指令--mode
:可設置開發版本 (development) 及產品版本 (production),差別在於有沒有進行最小化壓縮。--watch
:持續監控編譯後的檔案,當來源檔案有變動時立即重新編譯,執行後若想終止監控,則按下 ctrl + c
終止。module
屬性在 all.js 內使用 let
以及 const
來宣告變數
先使用 npm run dev
觀察是否有透過 Babel 並且由 Webpack 編譯輸出:
看起來是有順利輸出,來看看長什麼樣子
產生了一大堆的程式碼,而我們也可以發現原本使用 let
以及 const
來宣告的變數都被換成 var
了。
而瀏覽器的部分也順利運行編譯後的程式碼。
我們為了觀察而使用 npm run dev
,這會使編譯後的檔案並不是以優化的方式編譯,因此若做為正式發布,應該使用 npm run build
,這點要特別注意哦。
在學會如何模組化管理 JavaScript 與自定指令後,接著我們可以結合這些觀念,進行有系統的測試。尚未接觸這些東西前,我們在開發時如何檢測自己的函式寫的正不正確呢?沒錯,就是老老實實的使用 console.log
印出來,確認完之後再刪除,但這樣子很不方便,而這篇的主題就是為此而生。
index.html:引入一個使用 Webpack 打包後產生的 .js 檔案
export
出去require util.js
,並且令 all.js 為進入點這是最基礎也最常使用的,在還沒接觸更進階的知識前,我們要如何知道程式的結果是什麼、到底寫的正不正確、符不符合我們預期,就是使用 console.log
印出後自己判斷正不正確。
於是我們使用指令打包後,到瀏覽器確認結果
在我們還沒掌握更進階的技術之前,都是透過這種方式來確保自己的函式輸出的結果如同自己預期,但是現在我們有更好的選擇了,透過 npm 來安裝別人寫好的測試 module ,本次要介紹的就是 Jest 。
程式中最小的結構單位就是以 function 為劃分,像這樣測試每一個 function 的結果是否正確就是所謂的單元測試 (Unit Test)
Jest 是個 Facebook 開源計劃的項目,我們只要知道這樣就夠了,直接來動手做看看吧。
npm install jest --save-dev
安裝其實蠻簡單明瞭的,這段程式大致說明如下:
test()
是 Jest 提供的 function,第一個參數為文字敘述,第二個參數則是傳入一個 functionexpect()
則是傳入要接受測試的函式toBe()
就是我們預期得到的結果npm run jest
- 測試所有的測試檔案npm run jest 要測試的檔案
- 測試指定檔案馬上就得到錯誤,原因是我們並非全域的安裝 Jest ,所以沒辦法直接使用下指令的方式來運行。
因此有個做法是,當你的 npm 版本為 5.2 以上時,可以使用另一個指令:
與 npm 不同的地方在於,npm 是從作業系統開始找這個套件,npx 則是從專案開始找。
像這樣,很快的發現了我的第一個錯誤, require
路徑寫錯了,讓我修改一下。
像這樣,可以輸入 npm run test
,可獲得一樣的結果:
而為什麼寫在 package.json 就可以呢?原因是因為寫在裡面的話, npm 就會知道要從專案開始找,因此就不會跳出找不到的錯誤訊息囉。
要測試一個函式是否沒有 Bug 自然需要多個極端的測試案例,像是:
雖然這樣沒什麼問題,但對於有強迫症的人來說這樣子太沒有組織性了,所以我們可以像這樣把這一坨包起來:
describe()
也是 Jest 提供的函式,可以幫助我們整理這些零散的測試。運行看看吧!
而如果你故意寫錯的話~
你就會得到一大堆的錯誤,並且會告訴你「預期得到什麼」、「實際上輸出什麼」。
這樣是不是讓人更清楚這些東西是在幹嘛的呢?
覺得自己似乎又朝著前端更成長了一點,以前聽到單元測試,總覺得是個很厲害但令人摸不著頭緒的名詞,沒想到現在我居然也是個知道怎麼寫單元測試的工程師了。
]]>於[Webpack]No.1 模組化 JavaScript 的方法我們知道了如何模組化 JavaScript ,但是每次只要有 .js 檔案有異動,就得輸入指令重新 bundle 一次,好麻煩啊。別擔心,工程師是最怕麻煩的生物,早就有措施囉,這篇要介紹的是其中一種方式,自訂專屬於自己的指令。
為了能順利的寫下自己的咒語,我們需要建立一個 package.json ,而且讓我們先閱讀一下課外讀物,了解之間的差異。
npm init
會詢問你這個專案名稱叫什麼、版本號、等等的,如果都不想設定就快速的按下 Enter 吧。
最後會再次詢問你,是不是真的要建立。輸入 yes
於是我們就建立了 package.json。
接著就到重頭戲啦,打開 package.json 可以發現有一段是這麼寫的:
1 | "scripts": { |
這一段是預設給我們測試的,可以在下方輸入 npm run test
然後我們就會得到這樣的結果,聰明如你,應該知道怎麼運用了吧?
沒錯,我們接下來就是要把指令搬移到這邊。
修改如下:
“指令名”: “指令內容”
這邊輸入中文只是為了博君一笑,當然開發時才不會這麼做呢。
接著我們測試看看吧!輸入以下指令:
1 | npm run 重生吧前鬼我還你原形 |
看起來很順利的完成了!來看看瀏覽器的狀況吧~
運行也沒有問題,看來中文指令是大成功的哩~
當我們在開發 Vue 時,輸入的 npm run dev
、 npm run serve
其實不是魔法,現在你學會了這招,是不是也能來個有創意的指令呢?
]]>當學的越多,慢慢的知道框架其實就是個高度包裝過後的東西,本來覺得像魔法的東西也逐漸的了解,這種感覺真的很棒。
如果網站的規模不大,可能單純寫一支 all.js 就能搞定了,但如果今天與別人合作或者網站規模比較大,這時若只單靠一支 all.js 肯定是落落長的程式碼,每次要修改就得找半天,這樣是非常辛苦的。所以我們需要模組化 JavaScript ,這樣會方便很多,而模組化的好處遠不只這些,就不贅述了。
這邊的模組化的意思並不是把程式碼拆成多支 .js 檔並且在 index.html 內引入,因為這麼做其實並不算真正的把檔案分開,在 JavaScript 內仍然把它們視為同一個檔案,只是堆疊在一起,就好比這樣:
於是輸出結果,會發現 all.js 會把寫在 math.js 內的全域變數給印出來,這代表程式執行時它們會拼合成一支檔案,而透過觀察這麼做會產生 2 次的 request ,因為用了兩次的 script 標籤。
接著我們使用 ES5 module.exports
與 require
試著將 math.js 模組化…。
你會發現根本不能運行,因為 module.exports 與 require 只有 node.js 環境下才可以使用。
不管是 ES5 module.exports
與 require
或 ES6 import
與 export
,如果想在瀏覽器環境使用模組化 JavaScript 就必須透過 Webpack 來搞定!
打開 CLI 輸入 node -v
,若出現版本號代表安裝成功。
npm install webpack webpack-cli — save -dev
接著可以到 webpack 官網看看如何起手
好的,我們要先在專案資料夾內建立一個叫做 webpack.config.js 的檔案,接著複製貼上這些程式碼,而這些程式碼的涵義也很簡單:
entry
: 進入點,代表引用這些 modules 最主要的地方path
: 檔案輸出的路徑filename
: 檔名都設置完之後,大致上長這個樣子:
接著回到 index.html ,引用我們打包後的 bundle.js 檔案
這樣事前準備就都完成了,終於可以進入使用 ES5 module.exports 與 require 的部分。
.\node_modules\.bin\webpack
打包產生檔案接著我們就可以到瀏覽器上觀察囉~
發現函式的確成功的呼叫了,而且並不會受到 math.js 內的全域變數干擾,而且也因為只有使用一次 script 標籤,因此 request 只有一次。
math.js 內配置
1 | var math = '我是全域變數,會影響到其他人'; |
all.js 內配置
1 | const myModule = require('./module/math'); |
因為匯出的東西是物件,所以我們也必須使用物件的方式來取用。
別忘了使用指令打包輸出,接著來看看結果:
蠻簡單的,對嗎?
使用 ES6 比較尷尬的點是,有些流覽器並沒有完全支援 ES6 語法,因此必須使用 ES5 或者透過 Babel 來轉換 ES6 的語法,目前 export
與 import
支援的程度如下:
嗯…好像還是紅紅的,所以要使用之前還是得先查一下支援程度,或者就乾脆使用 Babel 搞定這一切。
可以看出跟 ES5 的差別在哪裡:
export
代表匯出該函式import {}
承接, {} 內變數名稱需與匯出的函式相同export
的東西不只函式,變數也可以*
號配合 as
賦予別名而這個 myModules
是什麼呢?
是個物件,所以可以像一般使用物件的方式一樣操作即可。
export
,可以這麼做這邊要特別注意的是
export{ }
並不是物件
export
配合 default
,就可以 import
時不加 { } ,但只能有一個 default。
是不是覺得 import 沒有 { } 感覺比較順手呢?
個人比較喜歡取 import *
號取別名配合 export{ }
的方式,感覺最順手。
ES6 雖然好用歸好用,不過這樣的方式似乎目前支援度還是蠻差的,還是得透過 Babel ,如果確定專案會用上 Babel 了,那就不用客氣的用吧!
如果不想使用 Babel 那就使用 ES5的 require
+ module.exports
囉。
這邊撞了蠻多牆的,因為一開始很單蠢,不知道模組化必須得透過 Webpack 才能進行,還很納悶的想說語法都沒錯怎麼不能跑。
]]>這周是參加計劃的第二周,每一周都過得很充實,不斷為自己打底的同時,也期待未來能遇到好的公司。之前繳交 OK 的作業也都可以透過這個 repo 看到,放上來也算是一種督促自己的心裡吧。
這篇主要分享一些第二周時寫的一些初級 JavaScript 題目,題目是由 Huli 擬定的,剛接觸 JavaScript 的朋友可以寫看看。
請你分別用 for loop 以及 while 迴圈,印出 1 ~ 9
1 | // 1 |
請寫一個函式叫做 print,接收一個是數字的參數 n,並且印出 1~n
1 | // 1 |
寫一個函式 star,接收一個參數 n ,並印出 n 個 *
1 | // star(1) 預期輸出: |
請寫出一個叫做 starReturn 的 function 並且接受一個參數 n,能回傳 n 個 *
1 | // console.log(starReturn(1)); 預期輸出: |
請寫一個叫做 isUpperCase 的 functuon,並且接收一個字串,回傳這個字串的第一個字母是否為大寫
1 | // isUpperCase("abcd") 正確回傳值:false |
請寫一個 function position,接收一個字串並回傳這個字串裡面的第一個大寫字母跟它的 index,若沒有則回傳 -1
1 | position("abcd") 正確回傳值:-1 |
請寫出一個函式 findSmallCount,接收一個陣列跟一個數字 n,回傳有多少個數小於 n
1 | // findSmallCount([1, 2, 3], 2) 預期回傳值:1 |
請寫一個函式 findSmallerTotal,接收一個陣列以及數字 n,回傳陣列裡面所有小於 n 的數的總和
1 | // findSmallerTotal([1, 2, 3], 3) 正確回傳值:3 |
請寫一個函式 findAllSmall,接收一個陣列跟一個數字 n,回傳一個裡面有所有小於 n 的數的陣列(需按照原陣列順序)
1 | // findAllSmall([1, 2, 3], 10) 正確回傳值:[1, 2, 3] |
請寫一個 function sum,接收一個陣列並回傳陣列中數字的總和
1 | // sum([1, 2, 3]) 預期回傳值:6 |
請寫出一個 function stars,接收一個參數 n,並且按照規律印出相對應的圖案
1 | // stars(1) 預期輸出: |
請寫出一個 function makeStars,接收一個參數 n,並且根據規律「回傳」字串
1 | // makeStars(1) 正確回傳值:* |
請寫出一個函式 stars2 ,接收一個參數 n ,並依照規律印出圖形
1 | // stars2(1) 預期輸出: |
請寫一個函式 table,接收一個數字 n ,印出 n 1 ~ n 9 的結果
1 | // table(1) 預期輸出: |
請寫出一個 function table9to9,並列出 1 1 ~ 9 9
1 | // table9to9() 預期輸出: |
費式數列的定義為:第 n 個數等於前兩個數的總和,因此這個數列會長的像這樣:1 1 2 3 5 8 13 21 ….
1 | // fib(0) = 0 |
請寫出一個函式 reverse,接收一個字串,並且回傳反轉過後的字串。(禁止使用內建函式 reverse)
1 | // reverse("abcd") 預期回傳值:dcba |
請寫一個函式 swap,接收一個字串,並且回傳大小寫互換後的字串
1 | // swap("Peter") 預期回傳值:pETER |
請寫出一個函式 findMin,接收一個陣列並回傳陣列中的最小值。(禁止使用內建函式 sort)
1 | // findMin([1, 2, 5, 6, 99, 4, 5]) 預期回傳值:1 |
請寫一個 function findNthMin,接收一個陣列以及一個數字 n,找出第 n 小的數字。(禁止使用內建函式 sort)
1 | // findNthMin(\[1, 2, 3, 4, 5\], 1) 預期回傳值:1 |
請寫一個 function sort,接收一個陣列並且回傳由小到大排序後的陣列。(禁止使用內建函式 sort)
1 | // sort([ 6, 8, 3, 2]) 預期回傳值:[2, 3, 6, 8] |
請寫出一個 function flatten,接收一個多維陣列並回傳「壓平」後的陣列。
1 | // flatten([1, 2, 3]) 預期回傳值:[1, 2, 3] |
請寫一個 function tree,接收一個數字 n,按照規律列印出大小為 n 的聖誕樹
1 | // 為方便顯示,因此把空白代換成底線,實際請輸出空白 |
請寫出一個 function winner,接收一個代表圈圈叉叉的陣列,並回傳贏的是 O 還是 X,如果平手或還沒下完,請回傳 draw
1 | // winner([ |
請寫出一個 function isPrime,給定一個數字 n,回傳 n 是否為質數。(質數的定義:除了 1 以外,沒辦法被所有 < n 的正整數整除)
1 | isPrime(1) 正確回傳值:false |
總共 25 題,目前的能力大概是 22 題能初見殺,不過用的時間比較多一些,還沒辦法秒解,有 3 題是不太熟或者完全沒想到所以放棄的。
解題的過程蠻有趣的,也是再練習如何把問題切碎切小,而有些題目彼此也有關聯性,這邊也附上我自己解完題的答案:
]]>在經過了一些摧殘後,初學的菜鳥雖然對於 PHP 語法還不是相當熟,但起碼有些微的語感了,這篇沒有什麼重點,只是簡單交代一下最近用什麼小題目練習 PHP,而基礎的 PHP 大概就到此先告一段落。
我總共做了四種不同類型的應用,不過基本上都脫離不了 CRUD , 兩種是純粹的 PHP 練習,另外兩種則是練習用 PHP 開 API 做前後端分離。
畫面都頗陽春,主要著重在功能面的實作練習。
這個題目是我剛接觸 PHP 的第一個題目,屬於純粹的 PHP 練習,沒有任何 JavaScript 。
主要練習:
作為接觸 PHP 的第二個題目,同樣是屬於純粹的 PHP 練習。
主要練習:
接著就是練習如何開 API 給前端接了,這個就是大家熟悉新技術通常都會拿來練手的題目,然後也重新複習一些 jQuery 。
主要練習:
最後就是練習開更多的 API ,做更多的 CRUD、寫更多的 PHP 。
主要練習:
大概就是這樣吧,雖然不敢說非常熟悉 PHP 了,但至少有一點上手了。基礎的 PHP 到此告一段落,接著會想嘗試挑戰看看 Laravel 。
]]>No.4 寫完之後,我果然還是很在意自己這樣子寫可不可以,於是我就請了一位大大來幫我看看,是不是有哪些地方可以調整,然後給了我三個建議。
寫出來就能比較清楚知道要保留、刪除、新增哪些。
而我之前的做法是無差別刪除,然後新增 (比較簡單、偷吃步)
因為本身還在學習 PHP ,所以對 PHP 語法可能不夠熟悉,因此不知道如何動手,所以才有那種相當耗資源的寫法。於是給我建議的大大說,那不然你把它想成這樣的結構
1 | [ |
試著用 JavaScript 去把這個資料轉化成你想要的樣子。
老實說我這題卡了 6 小時,看起來需求很簡單,不過實際上當真的要動手寫的時候,會發現腦袋幾乎是空白的,再次驗證程式不能只用看的、想的,要真的動手寫。
一開始,我把注意力都放在 ES5、6 的陣列方法上, 像是 reduce
、 filter
、 foreach
等等,怎麼樣就是不知道該怎麼繼續做下去。
也查了一些什麼物件合併、陣列合併的方法,但好像有看跟沒看一樣。
後來大大給了我其他的提示:
for
迴圈就能解決1 | [ |
於是我好不容易拼湊出來了,在卡了好一陣之後。
1 | let dataLen = data.length; |
接著說也奇怪,我似乎慢慢地知道該怎麼做了,於是我把資料補回原本的那樣,然後繼續維持輸出成物件的形式。
for
迴圈,肯定要知道這個陣列的長度data
陣列內 的這個物件跟下一個物件是不是一樣的」如果一樣,需要對分類做合併;不一樣就新增
對於第二點,我的做法是使用「type of
檢查 obj
物件,如果物件內沒有對應的 id
屬性,會顯示 undefined
,代表這筆資料是不同的,必須新增」
1 | let data = [ |
後來我覺得還是把 id
也包進物件內好了,所以又自己補上去。
到這邊就整個都通了,接著要把它換成一開始要求的樣子,於是我查到一個好用的方法 Object.values()
,補上即可轉換。
1 | let arr = Object.values(obj) |
最困難的部分已經克服了,接著就是要回到 PHP 實作,邏輯基本上都沒變,只是語法要稍微調整一下,相信不是太大問題。
最後 PHP 部份我也順利的完成了,這一篇主要記錄的是「當遇到問題,如何把問題切細,變得容易解決」,非常感謝給我建議的大大,也花了不少時間引導我。
]]>突然意識到似乎很久沒有上來這邊寫寫文章了,最近都在埋頭練習 PHP 與 MySQL ,實做了一個超級陽春的 job board 與 blog ,初期弄一個簡單的 blog ,自然是沒什麼問題。但隨著需求的提升,就困難許多了,像是「把一篇文章只能有一個分類,變成一篇文章允許多個分類」,這件事情就會讓難度高上不少,而這篇就是記錄這個過程哩,順便也描述一下自己遇到什麼困難。
做這樣的改變,首當其衝的必然是資料表與資料表之間的關聯,如果「一篇文章只有一個分類」,我們只需要再 article
的資料表內新增一個 categoryID
的欄位,然後需要時,透過 join
查詢這樣就搞定了。
但如果是「一篇文章有多個分類」呢?
articles
的資料表內新增一個 categoryID
的欄位,利用字串拼接的方式儲存?ref
表,儲存文章與類別的關聯性我最後想想是選擇了第二種方式,感覺比較好。
主要是 PHP 與 MySQL 與 HTML 上的調整。
由於變成可以選擇多個類別,所以這部份的選單需要變成多選式的,而我也是第一次發現可以再 name
屬性內加上 [] ,使其傳回陣列。
1 | <select multiple="multiple" name="category[]" id="category"> |
這部份我是選擇分開處理,因為根據畫的 modal 圖, articles
這張表其實跟類別已經沒什麼關係了,所以我選擇拆成兩段:
id
ref
表內新增文章與類別的關聯而資料庫部份使用 PDO 來連接,認為比較關鍵的語法就是
1 | $pdo->lastInsertId(); |
這可以讓我取得最後一筆新增的 id
,這樣我就知道新增的那篇文章 id
是多少。
主要是 PHP 與 MySQL 的調整。需要在 admin.php (後台) 把文章資料讀出來,但這部份也是我比較苦惱的,因為對於 SQL 的語法並不是很擅長,這一段雖然我有做出來,但總覺得如果我更熟悉 SQL 語法,應該可以更好做。
這部份我試了好一陣,總是沒辦法單獨使用 MySQL 就把文章、類別、關聯表的資料漂亮的串好,頂多就串成下面這樣。
1 | SELECT a.*, b.name |
但這樣我也不知道該怎麼用,因為我想要得到的結果是,每篇文章只有一筆資料,然後後面會帶上類別名稱這樣。
這部份我卡了很久,後來決定用比較笨的方法,跟新增文章一樣分成兩段:
foreach
,取得每篇文章 id
後,再透過 SQL 語法取得該文章對應的類別,最後插入陣列。因為不確定這麼做好不好,感覺這樣很沒有效率。
主要是 PHP 與 MySQL 與 HTML 上的調整,這部份的 HTML 畫面也讓我卡了好一陣子,原因是當類別變成多選後,進入編輯時需要將對應的類別設定預設選取,這部份會卡關主要是邏輯卡住了,因為我用了兩層的 foreach
,腦袋轉不過來,應該會有更好的解法但我不知道。
這部份我也是不知道有沒有比較好的做法,也不確定我這麼做對不對。因為如果是更新到類別的話,勢必得到 ref 表內做一些查詢、修改,也有可能把原本的 3 個類別改成 2 個類別,那這樣子要怎麼做出相應的處理呢?
所以這部份我後來想到的做法是
ref
表內刪除所有跟這篇文章的關聯並重建關聯這麼做的好處就是不用管要怎麼處理了,反正就是重建新的關聯,只是我不確定這麼做好不好就是了,只是這樣子做讓我輕鬆不少。
主要是 PHP 與 MySQL 的調整,不過也不是說想刪除文章就可以直接刪除文章,MySQL 是關聯式資料庫,從 modal 上看來,文章關係到了 ref
表與 comment
表,所以如果直接針對文章做刪除是肯定會失敗的。
所以這部份的做法是:逐一刪除有關聯的部份
ID
刪除 comment
內對應的資料ref
表內對應的資料刪除的部份是相對簡單的呢。
折騰了一陣子總算是把功能都做完了,不過卻也開始懷疑這樣的做法 O 不 OK 就是了,但有做出來總是好的!
畢竟先求有再求好,對吧?
]]>開始進行 PHP 的練習後,最重要的大概就是資料庫的操作了吧,對後端而言與資料庫的關係像是魚跟水般密不可分,我在實作練習時發現 PHP 有三種方式可以跟 MySQL 連線,所以想記錄一下有哪些,不過這些大致上都有前輩寫過了,所以就只是單純記錄。
經過一輪的比較,似乎是 PDO 用途會比較廣,畢竟 PDO 連接資料庫時,透過 Data Source Name (DSN) 來決定要連接何種資料庫,只是我還有點不習慣 PHP ,所以寫起程式碼來綁手綁腳的。
因為 PDO 有支援 try...catch
所以也可以好好運用,找到一篇蠻完整的範例。
正當我興高采烈的想說 MySQL 的環境都設定好、前端版型也都 OK 了,該是來好好的學習一下 PHP 了。這時才發現,PHP 的 Debug 環境需要特別去設定,不像 JavaScript 這麼佛心只要打開 chrome 就能除錯,又撞牆了好一陣,終於把開發環境都建立起來了。
事前準備你需要:
下載好且安裝成功後重啟 VS Code,開始進行設定。
隨便開一個空的 php ,並且在裡面寫上
1 | <?php |
進入到這個網頁,會看到這個畫面。
接著對著這個畫面直接全選複製 (Ctrl + A)
並且到 PHP Debug 的 XDebug installation wizard 貼上。
按下按鈕,讓程式偵測還需要設定什麼。
很顯然的必須照著這些步驟去做,分別是
1 | zend_extension = C:\MAMP\bin\php\php7.2.10\ext\php_xdebug-2.7.0-7.2-vc15.dll |
存檔後重啟 MAMP ,另外路徑可能會有不同,適當做調整即可。
到這邊 PHP Debug 與 MAMP 的設定就結束了,不過還沒完,還有 VS Code的部份需要調整呢!
打開 VS Code -> 設定 - > 搜尋設定 -> 輸入 PHP
點擊「在 settings.json 內編輯」,然後順著裡面的格式補上這一段:
後面的路徑也需要配合實際檔案位置作調整,儲存後就可以關閉囉。
至此,設定就全數完成囉,來使用看看吧!
首先我們可以在寫好的程式上加入紅色的中斷點
接著打開 VS Code 左手邊的偵錯工具,選擇「Listen for XDebug」,並且按下綠色的三角型播放鈕,開始偵錯。
接下來可以切換到這支 php 網頁,實際運行看看。
會發現程式將停在我們設置的中斷點上,等待操作。
此時因為尚未進入 $foo = 1
,因此 $foo
還是 uninitialized
的狀態,接著可以按下上方的逐步執行,觀察變化藉此除錯。
太棒啦~到這邊終於可以開始寫 PHP 了。
在前端常見的套件管理常常聽到「NPM」、「Yarn」之類的,然而在 PHP 也有類似的套件管理工具哦,那就是 Composer 。
根據作業系統不同有不一樣的安裝方法,我是 Windows 系統。
安裝過程中會有這個畫面
在這邊要選擇目前執行的是哪個版本的 PHP ,預設會是空白的,要自己點擊 Browse… 去尋找。
之後就一直按下一步維持預設值就行了。
打開命令提示字元 (cmd) ,輸入 composer
看到以下畫面代表安裝成功。
試著下載 kint-php/kint 這個套件,在 VS Code 內的終端機輸入指令即可。
關於這個套件的敘述:
1 | composer require kint-php/kint |
接著會發現專案資料夾內多了這些檔案:
最後在 php 檔案中引入即可使用囉!
1 | require 'vendor/autoload.php'; |
後端真的很坑啊,真心不騙。
PHP 對 MySQL 的資料庫連線我還有得撞牆呢…OTZ
]]>好不容易搞定 TodoList 前端部份,接著就是後端了。後端的部份我想使用 PHP + MySQL 來處理,但是這部份我幾乎完全沒有概念,到處碰壁,所以想要好好的記錄一下今天做了哪些事。
引用 WIKI 百科的解釋 - MySQL Workbench 是一款資料庫設計和建模工具,專門為 MySQL 設計。
而我實際操作起來的感受的確也是這樣,我們可以在上面規劃、管理資料庫,最後再利用同步的功能將我們在上面設計的東西推到資料庫上,過程中一行程式碼都不用打,因為都幫我們處理好了。
這感覺就好像,Workbench 是用來跟 MySQL 溝通的橋梁?
也因為 Workbench 會自動生成對應的 MySQL 語法,因此 Workbench 內 Preferences 的 MySQL 版本以及目標的 MySQL Server 版本就很重要。
簡單來說它就是一個初學者用的東西,因為幾乎不需要額外設定什麼就能使用,很適合一開始要接觸後端的人。
裝好大概長這樣,沒有什麼很複雜的設定。
首先我們要準備一份 Models ,這是等等要同步到 MySQL Server 的東西。不過這部份就不是本篇文章的重點,就不贅述了。
目標是連到 MAMP 上的 MySQL**
這個步驟相當重要,就是因為這個步驟害我卡住…。
原因是我下載的這個 Workbench 是 8.0 版本,裡面預設是較高版本的 MySQL ,因此 Workbench 自動生成的 MySQL 語法自然也是較高版本的。
而 MAMP 上的 MySQL Server 版本是 5.7.24 ,自然不能相容較高版本,因此若沒有先行設定這邊,後面同步的操作可能會失敗,所以在這邊必須要先進行調整。
最坑的莫過於指定 MySQL 版本居然不是做成下拉式選單,必須自己把版本輸入正確,輸入錯誤框框會反紅表示。
基本上如果前面有設定好的話,這邊就是一直 Next ,然後調整一下是哪邊要 Update 到哪邊,這樣就結束了。
嗯~看起來都有呢,真棒!
希望透過這樣的整理可以幫助到比我更菜的人,不會因為找到的文章都是一些艱深難懂的名詞而放棄學習。而我也可以透過這樣的方式加深自己的印象。
]]>最近正著手往全端的技能樹點,然而又重新做了一份 TodoList 的練習,然後因為對冒泡事件的誤解,因此決定寫下來。
點擊 label 與 checkbox 時,會切換 complete 的狀態,但在 li 上有個雙擊事件,我不希望在快速切換 complete 狀態時會觸發雙擊的事件。
因此我最初的想法是「可能是因為冒泡事件導致的」,畢竟點擊的是包覆在 li 內的 label 與 checkbox 。
於是我在 complete 狀態切換的事件上加入 e.stopPropagation();
但是沒有效果。
正當我不理解為何阻止冒泡事件不起作用時,我動手寫了個簡單的小範例,驗證自己的想法有沒有錯誤。
神奇的事情發生了,果然就是我誤會它了…。
最後我在雙擊的事件中補上判斷,當雙擊的目標是 label 或 checkbox 就離開雙擊事件。
1 | if(e.target.nodeName === 'INPUT' || e.target.nodeName === 'LABEL') { return }; |
這一篇就是 JavaScript Weird 系列的最後一節了,接下來我們將繼續完成上一節沒有完成的部分~並且做一個很陽春的介面,選擇語言後按下按鈕就呈現對應的打招呼語句。
繼續下個步驟之前,要先幫一個地方補上上一節寫好的驗證,這樣就能在一開始使用時就檢查出有沒有支援這個語系。
1 | <div class="loginblock"> |
我們要將結果輸出到網頁上,這部分除了使用 jQuery 之外當然也可以透過原生 JavaScript 來達成,不過本次的目標是使用 jQuery。
在原型下新增這個方法
1 | HTMLSayHello(selector){ |
接著我們就來使用看看吧,順便測試一下鏈式寫法
很順利的成功了~這樣我們就完成了一個非常陽春的 library ,當然還有很多細節沒有處理,不過這不是本次的目的。
本次的目的是在於
最後,JavaScript Weird 部分也結束了,沒想到我居然可以堅持這麼久,莫名的有成就感,總算是填坑完成啦!
透過寫作也間接強化了對 JavaScript 底層觀念的認知,甚至我連支線都一起寫進去了,這一切感覺起來是這麼的不真實。
翻著這些文章,想著原來這段時間我學了這麼多東西,這應該也是另類的學習歷程吧?
接下來的目標會放在接觸後端上,畢竟想在高雄求職似乎也會要求後端,所以大致上會以 PHP Laravel 為新坑目標~
]]>我跳過蠻多小節的,因為看起來沒什麼好紀錄的,這篇主要是紀錄看了 jQuery 的原始碼後,可以從中學習的技巧,以及綜合練習之前觀念。
大致上是這樣,那就讓我們開始吧。
window
、 jQuery
(之後會用到)1 | (function(global, $){ |
建立 constructor 內容,並且在回傳時補上 new
1 | (function(global, $){ |
設置要加入到 prototype 的方法
1 | sayHelloer.prototype = {} |
設置原型
1 | sayHelloer.init.prototype = sayHelloer.prototype; |
設置外部如何取用
1 | global.S$ = global.sayHelloer = sayHelloer; |
這樣外部就可以使用 S$
或是 sayHelloer
呼叫方法囉。
於是這一步驟的程式碼整理如下:
1 | (function(global, $){ |
運行並且試著印出來
1 | var s = S$('小明','王','TW'); |
這個 library 是拿來打招呼用的,所以大致擬訂一下需求:
return this
實現鏈式寫法1 | // 要加入至 prototype 的方法 |
因為我們之前有 return new
的技巧,所以這邊不需要使用 new
。
1 | var s = S$('小明','王','TW'); |
至此,大致上就快完成了,不過還剩下一些項目,我們將在下一節補齊。
]]>好的,終於又回到主線了,趕緊把這個坑填好。這篇主要是介紹關於 JavaScript 的嚴謹模式 (Strict mode),當開啟這個模式後,JavaScript 的部分行為就會比較不一樣了,就讓我們一塊來看看吧。
JavaScript 的特性就是比較彈性自由,但為人詬病的也是因為太過彈性自由導致缺乏規範,讓沒有經驗的開發者很容易就寫出預料外的 BUG。
而嚴謹模式就是在告訴 JavaScript 要用比較多的規範、限制來編譯這些程式碼,雖然這沒辦法完全改變 JavaScript 過於自由的問題,但還是幫助我們避免一些奇怪的錯誤。
1 | var person; |
像是把 person
打成 persom
的錯誤, JavaScript 會認為這是對的。
而這麼做不會錯誤的原因是,JavaScript 把 persom
設定為 全域 window
物件內的屬性,顯然地這並不是我們所預想的樣子。
因此我們需要告訴 JavaScript 打開嚴謹模式。
1 | 'use strict' |
當然嚴謹模式能做的不只這些,更多特性可以到 MDN 的文件查看:
嚴謹模式允許全域使用以及個別地在函式使用,使用方法很簡單,就只需要在 JavaScript 檔案的第一行或者是在函式內的第一行內宣告使用即可。
1 | 'use strict' |
1 | function test(){ |
因為嚴謹模式並不是必要的,這只是一個額外的功能,而且並不是每個 JavaScript 引擎的嚴謹模式特性表現都一樣,因此在使用上會有比較多的限制。
但如果我們仍然希望讓 JavaScript 更嚴格,可以試著使用 ESlint 配合 TypeScript 讓程式碼的品質更上一層樓。
]]>接續上一篇的 this
,我們將利用一種比較特別的方式來看 this
的值,以及介紹除了 call()
、apply()
之外還可以使用 bind()
強制綁定 this
,最後將提到 this
在箭頭函式下的特性。
上一節提到 this
基本只有在跟物件導向扯上關係的時候才有意義。
1 | use strict' |
而這個例子的輸出結果 this
指向的是 obj
。
有沒有發現幾乎每次
this
有改變的時候都是ooo.xxx()
之類的呼叫方式
同一個例子換個方式改寫結果就不同了
1 | 'use strict' |
這個例子就是在說,明明是同樣的輸出結果,但只要改變了呼叫的方式, this
就不一樣了。
是不是覺得要判斷 this 值是什麼有點困難?
可以試著帶入 call()
的方式去想!
1 | 'use strict' |
透過帶入 call() 的方式去想將有助於理解 this 的值是什麼。
以 obj.test()
來說,可以想像成在 obj.test()
後面補上 call()
並且填入呼叫 test()
之前的內容,就是 this
指向的地方。
在來個例子
1 | 'use strict' |
一樣使用上面提到的方式去判斷,因此可以得知結果的 this
會指向 obj.inner
。
所以現在就可以了解,第一個例子為什麼換個方式改寫輸出會是 undefined 了。
1 | 'use strict' |
call()
,所以會指向 undefined
this
吧1 | function log() { |
上一節介紹的 call()
、 apply()
主要是呼叫該函式,並指定 this
值的指向。但是 bind()
並不一樣:
bind()
會回傳一個一模一樣的函式,並且將 this 值強制綁定而且沒有辦法透過 call()
、 apply()
改變 this
值
1 | 'use strict' |
bind()
的用法其實跟前面兩個蠻接近的,第一個參數都是指定 this
在介紹之前做個小練習
1 | class Test{ |
輸出的 log 分別為:
但如果將 setTimeout 內的函式改成箭頭函式呢?
1 | class Test{ |
答案就會很明顯的不一樣囉。
與 this 的特性相反
但箭頭函式內的 this
箭頭函式內的 this
取決於被寫在哪,來決定 this
是什麼。
以這個例子來說,因為這個方法是這樣被呼叫的 t.run()
,所以 run 函式內的 this
會指向 t
,而同樣在 run
函式中的箭頭函式 this
也會跟著變成 t
。
而我們也可以透過這樣來觀察
1 | class Test{ |
當我們指定 this
後,也會連帶的影響到箭頭函式內的 this
。
也就是說箭頭函式內的 this
會是在被定義時那個區域的 this
值。
1 | function test(){ |
像是這個例子來說,如果沒有使用 call()
指定 this
,這邊印出的 this
值會全部都是全域 window
,但因為現在有指定 this
,所以全部都是 ‘aa’ 。
總算是把支線部分全部都讀完了,真的是對我的 JavaScript 底層的理解相當的有幫助,並且也把一些在奇怪部分 (我以為我懂但其實沒有) 的觀念釐清,像是我很喜歡模仿 JavaScript 引擎、找作用域、從 ECMA 理解 hosting 那些方式,像是偵探在找線索般,一層層的往回推,最後就可以理解為什麼會是這樣。
]]>接下來就是把剩下的主線完成, JavaScript 的坑就算完成啦。
結束物件導向的學習後,最後才介紹到 this
,這個安排是相當特別的。在奇怪部分時,記得是直接學習 this
是什麼以及什麼樣的情況下 this
是什麼值,而沒有認知到 this
這個關鍵字是為了物件導向存在的。
了解物件導向的觀念之後, this
就不是那麼困難了,雖然在講解物件導向的觀念時已經有使用 this
,但應該不難猜出 this
是什麼意思。
this
在英文裡面是「這個」的意思
而 this
是給物件導向觀念內使用的關鍵字,目的是「代替現在對應到的實例(instance)」。
在這個範例中有變數 d
與 c
分別指向的兩個物件實例,而 dog
類別內的 howling
方法,裡面會印出 this.name
的值。
意思就是不同實例呼叫這個方法時,因為 this
指向不同會有不同的結果。
所以說在物件導向裡面,
this
是相當有用而且必要的。
現在我們知道 this
大概是為了物件導向而存在,那如果在非物件導向的情況下使用會發生什麼事呢?
1 | function test(){ |
在一般情況下 this
會指向全域的 window
物件,而在 node.js 會印出 global
。
但這樣的情形其實蠻奇怪的,因為 this
在這個範例中實際上應該沒有任何東西才是,為什麼會指向全域的 window
物件呢?
1 | 其實是因為 JavaScript 預設是一般模式,切換到**嚴謹模式**就不同了。 |
這樣子的結果就合理多了。
1 | 'use strict' |
這樣也會是 undefined
this
基本上跟函式沒有什麼太大的關連,也就是說 this
在非物件導向的情況下基本都會是預設值,只有一個例外。
1 | document.querySelector('.btn').addEventListener('click',function(){ |
這個時候的 this
就會是使用者實際操作到的東西。
像是這個例子是 click, this
的內容就會是使用者在網頁上點選到的元素。
之前在介紹 new
的行為時有用到 call()
的方法,其實除了這個方法外還有其他的方法可以改變 this
的指向,像是之前寫的:
因此這邊稍微複習一下 call()
與 apply()
不同的地方
1 | 'use strict' |
而差別在於
call()
能接受多個用逗號隔開的參數apply()
只能接受兩個參數且第二個必須是陣列。共同的目的
this
1 | 'use strict' |
支線終於也到尾聲了,還真是不容易啊…寫筆記居然堅持了這麼久。
還剩下一點點,請務必堅持到最後!
了解原型與原型鏈的關係之後,可以發現 JavaScript 就是利用這樣子的關係來產生繼承概念的,而在 ES6 之後也有更方便的做法。
1 | class dog{ |
像這樣,先設定出普通品種的狗,而且牠們都會叫。
1 | class superDog extends dog{ |
extends
使 superDog
繼承 dog
superDog
內沒有 constructor
函式,所以會向上層尋找 (dog) constructor
函式並執行因為有些時候會有一些共同的行為,這時候可以透過繼承的方式,這樣不用重新寫。
我們希望超級狗在被建立的時候立刻使出吠叫技能,於是我們想到可以在 superDog 的 constructor 函式內進行。
1 | class superDog extends dog{ |
但這麼寫馬上就遇到問題了。
因為在 superDog
的 constructor
函式內呼叫的是 dog
的 howling
函式,而在 dog
還沒執行 constructor
之前是不能使用的,因此會出現如下警告。
這時需要補上 super()
,意思就是先執行上一層的 constructor
函式
因此修正如下
1 | class superDog extends dog{ |
而因為已經於 superDog
內覆寫了 constructor
,必須透過 super
函式把 name
傳入 dog
內的 constructor
,否則會顯示 undefined
,故修正如下:
1 | class superDog extends dog{ |
或者是我們希望超級狗的吠叫可以再更強一點,於是
1 | class dog{ |
]]>如此一來是不是很方便呢~至此物件導向的觀念就了解得差不多囉,接著最後要了解的就是 this 啦~
所以我說那個 new
到底做了什麼事,為什麼一定要加上 new
呢?
我們沿用前面的範例
1 | function dog(name){ |
在了解 new
做了什麼之前,我們需要先喚醒另個世界線的知識
newDog
函式,並允許帶入參數 name
obj
變數並令其指向空物件.call()
方法執行 dog
函式,令其 this
改指向為 obj
所指向的物件obj.__proto__ = dog.prototype;
obj
這段程式執行的結果與原本使用 new
的結果輸出如下
由觀察得知,兩者是一模一樣的。
.call()
去呼叫 constructor
函式,並將 this
指向至新產生的物件.__proto__
」使其對應至相應的 .prototype
以上就是用狗的範例來說明函式建構子 new
在背後偷偷做的事情了。
我們用上一節的範例來解釋什麼是 原型 (prototype) 與 原型鏈 (prototype chain),然後也不難發現 JavaScript 很多底層的觀念都是鏈狀的。
1 | function dog(name){ |
上一節的內容中,我們知道只要於 dog.prototype
上新增方法,這樣之後透過 new
產生的 dog
物件都具有 sayHello
方法。
以這個例子來說,當我們印出 d
的內容時,可以發現當中有個隱藏的屬性「.__proto__
」,也發現寫在 dog.prototype
上的方法在這邊出現了。
甚至會發現怎麼點開了「.proto」裡面還有一個「.proto」?
以這個例子白話的說,意思就是當在變數 d
指向的物件身上找不到對應的方法時,便從「.__proto__
」內尋找有沒有對應的方法,聽起來是不是跟之前解釋的 ScopeChain 有點像呢。
1 | ... 省略 ... |
而使用三等號來比較的話,可以得知兩者是一樣的。
d.sayHello()
的過程1 | ... 省略 ... |
d
指向的物件屬性內有沒有 sayHello
? 這個例子沒有。__proto__
屬性內有沒有 sayHello
,若有就停止不會繼續往後找下去。以這個例子來說,在步驟二時就找到了,那如果沒有的情況呢?
還記得上面的一張圖嗎?
怎麼點開了「
.__proto__
」裡面還有一個「.__proto__
」?
.__proto__
尋找,如 d.__proto__.__proto__
此時先抽離這個步驟,從這邊可以觀察出「.__proto__
」是一層一層的,而越後面的「.__proto__
」就越接近「底」的部分。
而本例中 dog 物件的「.__proto__
」下一層就是原始物件的 prototype
,所以下面程式碼為 true
1 | console.log(d.__proto__.__proto__ === Object.prototype); // true |
如何知道是不是已經找到底層了呢?只要輸出為 null
代表上一層就是頂層了。
1 | console.log(d.__proto__.__proto__.__proto__); // null |
.__proto__
尋找,直到找到為止1 | function dog(name){ |
可以得知這個過程是一層一層逐漸地往下找的,而這個過程被稱之為原型鏈 (prototype chain),其實跟範圍鏈 (scope chain) 有點相似。
這邊也順手觀察一下 dog.__proto__
是什麼
1 | console.log(dog.__proto__); // ƒ () { \[native code\] } |
因為 dog 本身就是一個函式,出現這樣子是符合預期的。
觀察至此,大部分的疑惑都解開,只剩下
new
了,接下來會提到new
到底做了些什麼。
可以進行一些比較有趣的事情,像是我們可以在 String 的原型上增加一個自己寫的方法,這樣之後只要型別屬於 String 就可以使用。
1 | String.prototype.getFirst = function(){ |
這節要來研究一下如何使用 ES6 新增的 class 實作出物件導向的範例,以及尚未出現 class 時,是如何處理這部分的?
直接看程式碼
1 | class dog { |
ES6 新增的 class
就像是一張設計圖,沒使用建構子 new
之前是沒有辦法使用的。
以這個例子來說,我們要建立狗的類別,所以我們定義了三個函式,其中 constructor
函式比較特別,當使用建構子 new
實例化 (instance) 狗的 class
時,可在括號內放入參數,而 constructor
函式可以取得該參數。
這個例子中以這樣的方式初始化每隻狗的名字,而我們可以更進一步的觀察
d
與變數 b
使用 typeof
觀察會是物件sayHello
方法,會回傳 true
,代表為同個函式。1 | function dog(name){ |
可以發現差別並不大,直觀來說下半部的程式碼幾乎是一樣的。
主要就是需要宣告一個函式,而這個函式其實就是 ES6 class
內的 constructor
函式。
而在 ES5 的時候,是看使用時有沒有加入建構子 new
來決定是否為 constructor
函式或是一般的函式。
然後除了要宣告一個函式之外,也必須在該函式的 prototype
定義這些狗會做什麼。
接下來進行一些觀察,可以得知跟上面那個例子的結果是一樣的。
至於 prototype
究竟是什麼,下一節才會提到。
這部分我可能之後會去找一下答案,不太清楚箇中差異,但個人蠻喜歡這樣的做法,因為我覺得蠻好懂的,畢竟就是物件罷了。
1 | var cat = { |
像這樣,建立一個貓物件,然後裡面定義一些方法。
接著使用 Object.create()
,這樣就結束了。
觀察後半部印出的內容,結果也與使用 class
實作出來的差別無異。
對照一下兩者內容:
而我的疑問是:
後來我找到了這兩篇
然而也想起為何奇怪部分的影片講師會比較推薦 Object.create()
的方式,以下截自上述連結文章內描述。
在其它的程式語言中,會用 class 這個關鍵字來設定該物件要長什麼樣子,然後透過關鍵字
new
來建立物件。
然而,和其他程式語言不同的地方在於,JavaScript 實際上使用的是原型繼承 (Prototypal inheritance)而不是古典繼承 (Classical Inheritance),所以為了讓 JavaScript 回歸單純的原型繼承,現在的瀏覽器大部分都支援 Object.create() 這種單純的方式來建立物件。
後來也取得了胡立大大的回應,在這邊整理一下:
也就是說,為了方便與其他人溝通,最好還是使用 class 會是最普遍的做法。
至於 Object.creat
的部分,如果搜尋的話應該會看到 Object.create(null)
,意思就是要產生一個「純粹的」物件,不繼承任何的函式,所以佔用的空間最少也很乾淨,例如說只是單純想存資料的話就會使用這個方法。
後來也找到關於 Object.create(null)
的詳細敘述與範例,Vue 裡面也有運用到哦。
在 JavaScript 的世界裡我們很容易聽見物件導向這樣的名詞,那麼究竟物件導向又是什麼東西呢?這是接下來要了解的內容,我們先從什麼是物件導向起手吧。
根據 MDN 的解釋是這樣的:
物件導向程式設計(Object-Oriented Programming、OOP)是一種程式設計方法。其將資料封裝(encapsulate)於物件(objects)中,我們需透過物件間接操作這些被封裝的內部資料,而非直接操作資料本身。
其實我們上一小節寫的閉包範例就有一點物件導向的味道了,讓我們回顧一下:
1361 行的時候,此時的 myWallet
是個物件,而我們透過了這個錢包物件間接的操作了被封裝的內部資料(像是 myMoney
),而不是直接的操作資料本身。
透過了這個間接的行為,可以讓我們更清楚的知道
在這裡自然是 myWallet
這個物件。
可是如果我們不使用物件導向的概念來寫,就像一開始寫那樣:
有個很明顯的缺點,就是:
儘管這兩種寫法都可以得到一樣的結果,但卻是有物件導向概念的比較容易被讀懂。
以上就是關於什麼是物件導向的基本認知,下一節我們要學習更多物件導向的基礎範例~
]]>好的終於到閉包的最後一個章節了,前面有提到一個小範例是關於金魚腦的小明,那除了這樣的情境可以利用閉包之外,還有一個情境是可以利用閉包達成的,讓我們一起看看。
情境是這樣的,我有 100 元,如果我的錢變多了,就利用某個函式讓錢增加,如果支出超過 10 元,最多就只能付出10元。
讓我們看一段沒有利用閉包的程式碼:
1 | var myMoney = 100; |
好的,這樣我們就完成了這段情境的敘述。
但是有個問題,如果今天與別人協作,別人如果沒有遵照我們訂的函式下去做增減,是可以直接對 myMoney
重新賦值 的,那這樣是不是不太 OK 呢?
因此我們要利用閉包將變數私有化,令別人無法在外部直接對變數賦值,只能透過我們提供的方法來變更值。
1 | function myWallet(InitMoney){ |
透過閉包可以確保讓我們的變數不會突然被修改掉,而且只能用我們定義好的函式對內部的變數進行操作,這樣一來可以確保變數是安全的。
朕不給的,你不能要!
接著就是物件導向的部分啦~不知不覺也複習了這麼多東西呢,也是該好好的認識一下物件導向了,尤其是物件導向的部分,這個名詞該如何簡單又白話的解釋呢?
]]>到了我最喜歡的部分囉~我滿喜歡透過小範例來討論一些寫程式可能會遇到的一些問題,這非常實用,在這個小節內我們要使用一些不一樣的做法來解決這個問題哦。
1 | var arr = []; |
這是蠻容易遇到的問題,在此的輸出不會如我們所想會是 0 ,而是變成 5 ,而我們接下來要嘗試不同的方法把問題修正。
i
變數相當於全域變數i
,轉而向上層尋找 i
這邊提到第一種解法,可以多宣告一個函式並且使用閉包技巧來記住當前 i
的值。
1 | var arr = []; |
可以把它想像成這樣
如果不想要額外宣告函式,也可以透過 IIFE 配合閉包的技巧來達成,關於 IIFE 的部分可以喚醒另外一個世界線的記憶(?
所以這段程式其實可以改寫成這樣,跟解法 A 差不多,只是把額外宣告函式的部分用 IIFE 取代掉了。
1 | var arr = []; |
我個人比較喜歡的一種,因為最容易。那就是使用 ES6 新增的 let
來處理這個問題, let
的作用域是以 block 也就是大括號來劃分,而 let 在迴圈中的表現出的特性又有點不同,每次迴圈執行時都會產生一個不同的 i 。
1 | var arr = []; |
使用 let
來處理這樣的問題其實相當容易,就只是把 var
替換掉而已。
然而我們可以把每當迴圈執行時的這段過程想像成這樣
而實際把右邊的內容放到左邊執行,結果是一樣的。
]]>接著我們繼續用類似的角度來觀察這一段閉包的程式碼。
1 | var v1 = 10; |
首先進入創造全域執行環境階段,初始化 VO 、scopeChain 以及設定 test
函式的 [[Scope]]
。
接著執行程式碼:
v1
賦值為 10test
函式進入 test
函式的創造執行環境階段,初始化 AO 、scopeChain 以及設定 inner
函式的 [[Scope]]
。
接著逐行執行 test
函式內的程式碼:
vTest
賦值為 20return inner
此時 testEC 執行完畢,被移出執行堆。
但因為 inner.[[scope]]
使用到 testEC.AO
所以 testEC.AO
不會被 JavaScript 的垃圾回收機制回收掉,因此會被保留在記憶體中。
最後回到全域執行環境,繼續運行程式碼:
inner()
進入 inner
函式的創造執行環境階段,初始化 AO 、scopeChain
逐行執行 inner
函式內的程式碼
innerEC.AO
內找不到變數 v1
,因此循著 innerEC.scopeChain
最後於 globalEC.VO
內找到,值為 10 。vTest
則為 20 。inner
函式執行完畢,移出執行堆。程式全部運行完畢,全域執行環境移出執行堆。
到此我們的 cosplay 就到一段落了。
偷用一下聳動的殺人標題,其實這個標題是可以解釋的。
我們之前提到,閉包白話來說就是一個函式裡面回傳一個函式。
而透過觀察,發現無論有無回傳, JavaScript 引擎背後紀錄的東西都是一樣的,像是沒有回傳也有像是 AO、VO、scopeChain 這些東西。
所以如果我們以這個角度「會記住這些周邊資訊的函式」來定義閉包,那就成了這次的殺人標題啦,所有的函式都是閉包,因為每個函式都會記錄這些東西。
不過一般提到閉包不會講到這種定義,一般來說都是講「一個函式內回傳一個函式」才是大家認知的閉包。
至此,關於閉包的原理以及觀念已經學習完了,接著要來看日常生活中可能會遇到的作用域陷阱以及閉包可以運用在哪,我個人也是蠻關注這一塊的,畢竟學了武功就是希望能派上用場 ~ 要是學了卻不知道能用在哪也是怪怪的。
學習到現在,覺得能慢慢地看懂程式碼,了解這些程式碼在背後偷偷做些什麼,讓人有點感動也覺得踏實,勉勵自己繼續投入。
]]>之前我們已經在 hoisting 嘗試過從 ECMAScript 文件內找出其原理,然後假裝自己是 JavaScript 引擎,但那個時候我們描述的不夠完整,因此這一小節要補足剩餘的部分。閉包其實也與作用域、範圍鏈脫離不了關係,因此在繼續深入了解閉包之前,還是要先對這兩者之間有更多認識才行。
於 hoisting 的章節時,我們有了執行環境 (EC)、變數物件 (VO) 的概念,本小節就繼續從這邊紀錄下去。
Every execution context has associated with it a scope chain.
每個執行環境都有範圍鏈 (Scope Chain)
When control enters an execution context, a scope chain is created and populated with an initial set of objects
大概的意思就是,當進入執行環境時,範圍鏈就會被建立
When control enters an execution context, the scope chain is created and initialised, variable instantiation is performed, and the this value is determined
當進入執行環境時,範圍鍊被建立且初始化,變數也被初始化並且確定其值
接著跳到 Function Code 的段落
The scope chain is initialised to contain the activation object followed by the objects in the scope chain stored in the [[Scope]] property of the Function object.
- 當進入執行環境時,範圍鏈的初始化將包含 activation object ,以及函式的
[[Scope]]
屬性- 當進入執行環境時,如有宣告函式,則將該函式的
[[Scope]]
屬性賦值為自身的範圍鏈
接著了解什麼是 activation object ,以下稱為 AO
When control enters an execution context for function code, an object called the activation object is created and associated with the execution context. The activation object is initialised with a property with name arguments and attributes { DontDelete }. The initial value of this property is the arguments object described below.
The activation object is then used as the variable object for the purposes of variable instantiation
- 大致上的意思就是,當我們進入一個執行環境時,會產生一個 AO (之前都說 VO ,但實際上是AO),而這個 AO 其實跟 VO 只有一些細微的差異,而大部分的行為都是相同的。
- 只有全域執行環境有 VO
接下來我們一樣假裝自己是 JS 引擎,分析一段簡單的程式碼:
1 | var a = 1; |
建立變數物件的時候,因為有宣告 test
函式,所以 test.[[Scope]]
被賦值為 自身執行環境的 scopeChain ,即為 globalEC.scopeChain
。
而初始化範圍鏈的時候,因為本身並不是函式,所以 ScopeChain 並沒有包含 [[Scope]]
屬性,僅有 globalEC.VO
。
整理過後可以得到下圖,全域執行環境的範圍鏈就是自己的 VO 。
創造階段結束後接著是執行階段,開始逐行執行程式碼。
a
賦值為 1test()
,創造並進入另一個執行環境目前的狀況是這樣的
創造執行環境階段
而 testEC.scopeChain
內的東西就是 testEC.AO
以及 testEC.[[Scope]]
屬性,透過代換後,可以發現 testEC.[[Scope]]
屬性就是 globalEC.scopeChain
,更進一步代換就是 globalEC.VO
。
接著逐行運行程式碼
b
賦值為 2inner()
,創造並進入另一個執行環境創造執行環境階段
如同在 test()
內發生的事一樣, innerEC
的 ScopeChain 會被初始化,裡面會有 innerEC.AO
與 inner.[[Scope]]
,接著可以透過不斷的代換,得知其實 innerEC
的 ScopeChain 會按照順序去找 innerEC.AO
、 testEC.AO
、globalEC.VO
內的東西。
接著逐行運行程式碼
c
賦值為 3b
,但自身 AO 內找不到,透過自身 ScopeChain 向 testEC.scopeChain
尋找,於 testEC.AO
內找到變數 b
為 2a
,但自身 AO 內找不到,且 testEC.AO
內也找不到,因此繼續往 globalEC.scopeChain
找,最後在 globalEC.VO
找到變數 a
為 1inner
函式結束,移出執行堆test
函式繼續執行test
函式結束,移出執行堆透過這樣的方式可以更了解作用域以及範圍鏈,也明白範圍鏈在 JavaScript 中是如何一層一層的往外找到相應的變數。
而我們模仿 JS 引擎的這個行為,除了可以幫助我們了解 hoisting 以及 範圍鏈之外,還能夠幫助理解閉包的行為,下一節我們將繼續使用這樣的方式來解析閉包。
]]>結束了關於 hoisting 的學習,接下來要討論的是 Closure 閉包,之前在奇怪部分也有紀錄到關於閉包的部分,但我期待接下來的幾節能帶給我不同的切入點,讓自己更了解閉包。
閉包也是個經常會被問到的問題,但我們在此先不要提理論,先使用一個簡單的例子來觀察閉包可以做些什麼事。
1 | function test(){ |
這是一個結果顯而易見的程式,答案是 11 。
但是如果不在 test
函式內呼叫 inner()
,而使用 return
回傳 inner
呢?
在這之前我們要先複習一個小概念,函式呼叫。
因為有沒有加上 () 是完全不同的兩件事。
1 | function test(){ |
使用 return
回傳 inner
函式,需要用一個變數來指向這個函式,方便後續使用它,也因為回傳的是函式,所以能直接加上 括號 () 執行,最後一樣能得到相同的結果 11 。
而神奇的是變數 a 的值會保留,原因之後再提。
如果不想額外宣告一個變數,也可以這麼做:
1 | function test(){ |
意思是當 test()
執行完畢 return inner
時,馬上執行 inner
函式。
當寫習慣之後,可以簡短成這樣
1 | function test(){ |
因為目的就是回傳被包在 test
函式內的那個函式,所以可以使用匿名函式的技巧表示被包在內部的函式。
總結目前階段所認知的閉包
呃 … 大概就是一個函式內又回傳另一個函式 ?
有個情境是這樣的,我們用一個函式做重複的事情,例如複雜的計算,那麼可以這麼寫:
1 | function openIcebox(item){ |
相當容易,不是嗎?
但是這麼做,每次呼叫都會執行一次 openIcebox 函式。
這麼做就好比
像小明這麼金魚腦的人,每次問相同的問題,就必須打開冰箱門確認一次,這樣是不是很浪費電?
所以小明需要一張便條紙把這些記起來。
1 | function openIcebox(item){ |
對於金魚腦的小明來說,函式 haveMemo
內的變數 memo
就是小明的便條紙,因此上面那個小故事,我們可以想成:
透過這樣子的比喻,方便了解透過閉包的特性可以做到什麼樣的事情,而從這個比喻了解到,小明節省了反覆開冰箱浪費的電力以及自身的體力。回到程式來說,透過這樣子的設計,能夠讓程式的效能更好。
為什麼說是設計:
]]>以上就是對於閉包 Closure 的初步概念,接下來我們要探討原理的部分。
前面三節提到的變數均使用 var
宣告,原因是 ES6 之後加入的 let
& const
在這部分的特性表現不一樣,本節將記錄它們有何不同之處。
當你看過之前的文章,或許你會試著觀察 let
與 const
對於 hoisting 上的表現,於是我們可能這樣子寫。
1 | console.log(a); // a is not defined |
或是這樣子
1 | console.log(a); // a is not defined |
然後我們可能就直接下了定論「 let
與 const
沒有 hoisting」
1 | let a = 10; |
混淆的點在於
let
& const
沒有 hoisting ,那麼變數 a
會往全域找到外部的變數 a
,但實際執行卻得到「a is not defined
」let
& const
有 hoisting,為什麼印出變數 a
時也會得到「a is not defined
」而不是 「 undefined
」?於是假設 let
& const
有 hoisting,只是表現出來的特性不一樣,導致我們認為它們沒有 hoisting 。
那麼 let & const 的 hoisting 特性是什麼呢?
暫時性死區 (Temporal Dead Zone) 以下簡稱 TDZ,是 let
& const
在 進行 hoisting 過程中產生的一種現象。
1 | function test() { |
換句話說 let
& const
是有 hoisting 的,只是表現出來的特性不一樣
var
宣告不同,這兩者不會於創造執行環境階段時被初始化為 undefined
let
宣告如果沒有賦值,執行到該行時則賦值該變數為 undefined
。1 | function test() { |
以上就是 let
& const
& TDZ 的簡單觀念,其實這部分還有相當多細部的觀念可以寫,其餘較詳細的部分可以參考這一篇。
其實在寫這一篇的時候,因為糾結於部分觀念,導致寫作時花了很多時間。主要是為了確認:
undefined
」,但考量時間因素,這支影片是在 ES6 之前推出,因此待求證。let
不會被賦予初始值 undefined
」1 | function test() { |
let
於創造執行環境階段時不會被初始化為 undefined
,那麼為什最後印出的結果會是 undefined
?let
宣告如果沒有賦值,執行到該行時則賦值該變數為 undefined
」undefined
,只是 let
因為 TDZ 的關係,必須等到實際程式執行到宣告的那一行時才能對變數進行存取」的確兩種情況都有可能,到底是哪一種只能看 spec 來確認。
我在《我知道你懂 hoisting,可是你了解到多深?》的最後面附了一大堆參考資料,其實都是很有用的資源。
這兩篇有你要的解答:
連結內的文章有一段這麼寫:
let
andconst
declarations define variables that are scoped to the running execution context’s LexicalEnvironment. The variables are created when their containing Lexical Environment is instantiated but may not be accessed in any way until the variable’s LexicalBinding is evaluated. A variable defined by a LexicalBinding with an Initializer is assigned the value of its Initializer’s AssignmentExpression when the LexicalBinding is evaluated, not when the variable is created. If a LexicalBindingin alet
declaration does not have an Initializer the variable is assigned the valueundefined
when the LexicalBinding is evaluated.
上面寫說執行環境被創建時,變數就被建立了,但是一直到「the variable’s LexicalBinding is evaluated」之前都沒辦法訪問,這就是 TDZ。
let a
接著又提到「If a LexicalBindingin a let declaration does not have an Initializer the variable is assigned the value undefined when the LexicalBinding is evaluated.」
如果宣告變數時沒給值,預設值會是 undefined
。
let a
這一行所以總結以上,你的第一個理解才是正確的。
到此我才確定自己的理解是正確的,感謝 Huli 大的熱心解答。
let
在創造執行環境階段時,不會被賦予初始值 undefined
let
宣告如果沒有賦值,執行到該行時則賦值該變數為 undefined
本來是不用特地把這一段再補上來的,但我認為可能也有很多初學者跟我有一樣的疑問,所以把來龍去脈整理出來,也有助於加深自己的理解。
]]>好的,接著我們要假裝自己是 JS 引擎,然後用 ECMAScript 文件上的規則來找出上一節開頭問題的答案哦~
1 | var a = 1; |
請依序寫出 log 答案是多少,在這邊我們要採用不一樣的觀點來找出這題的答案。
我們說過當 JavaScript 執行時,會先創造全域執行環境。
undefined
整理後可以得到這樣的結果
接下來開始逐行執行程式碼
a
被賦值為 1test
函式,建立並進入另一個執行環境基本上做的事情會跟創造全域執行環境時一樣,因此:
inner
函式a
變數整理後可得結果如下,至此 test
的執行環境建立完成。
接著逐行執行 test
函式內的程式碼
a
,此時對照 test
的 VO ,得知目前 a
為 undefined
a
被賦值為 7 ,此時 test
內 VO 的 a 為 7a
,對照後得知目前 a
為 7a++
, 此時 test
內 VO 的 a
為 8var a
,但已經有同名變數被宣告,直接跳過。inner
函式,建立並進入另一個執行環境至此,狀態如下
與前面介紹的一樣,會先創造執行環境,因此
所以當前狀態是這樣的
接著逐行執行 inner
函式內的程式碼
a
,但所屬 VO 內找不到變數 a
,轉而向上一層尋找,此時會找到 test VO 的 a
,所以會印出 8a
賦值,但所屬 VO 內找不到變數 a
,所以這邊的賦值其實是對 test VO 的 a
,因此被重新賦值為 30b
賦值,但逐層往上找也找不到變數 b
,最後會在全域執行環境內產生變數 b
,並賦值 200至此 inner
函式執行完畢,被移出執行堆。
目前執行堆最上方是 test
函式。
a
,此時對照 a
為 30至此 test
函式執行完畢,被移出執行堆。
a
, 此時對照 a
為 1a
賦值,因此 a
被修改為 70a
, 此時對照 a
為 70b
,此時對照 b
為 200至此,程式碼執行完畢,全域執行環境被移出。
接著我們可以實際運行這一段程式碼,會發現答案是吻合的。
是不是相當的神奇呢?
若要我說觀看影片到現在的心得,我覺得最屌的莫過於上一篇跟這一篇了,沒想到還可以用這樣子的方式了解 hoisting ,這是我上 JS 奇怪部份時完全沒有的體驗,真的是太棒了!
但我知道後面應該還有更多類似這樣的體驗,真的是很開心自己能有這樣的機會學習關於 JavaScript 底層。
]]>在受到前面兩小節的洗禮後,對於 hoisting 應該有更明確的認知,這節影片要帶領我們從 ECMAScript 了解 hoisting 的原理為何。
首先,讓我們打鐵趁熱,來份 hoisting 的考題吧,說不定面試會考哦?
1 | var a = 1; |
請依序寫出印出來的答案是多少。
還沒看解答之前我的答案是這樣,我把內容調換過用來幫助自己思考。
1 | var a = 1; |
後來對照答案後,發現第四題粗心寫錯了,應該是印出 30 才對。
但是其他都如同我想的,這代表先前的學習、寫文章記錄加深印象是有效的,往後我也會繼續這麼做。
可…可惡,JavaScript 的陷阱真多!
ECMAScript 是制定 JavaScript 的標準,因此也可以說是 JavaScript 的聖經,本節影片的作者要帶領我們如何透過 ECMAScript 的文件了解 JavaScript 的行為。
隨著時間過去 ECMAScript 的文件也會越來越多,但並不會影響原作者想要傳達的目的,本節會使用較舊的版本介紹。
以下節錄自文件
When control is transferred to ECMAScript executable code, control is entering an execution context. Active execution contexts logically form a stack. The top execution context on this logical stack is the running execution context
不過我拿去 google 翻譯看完之後發現根本不懂這敘述再寫什麼 (汗
但就之前的筆記,我對於執行環境的認知是:
以圖片來總結上面那些話,大概就長這樣:
資料來源: 我知道你懂 hoisting,可是你了解到多深?
以下節錄自文件
Every execution context has associated with it a variable object.Variables and functions declared in the source text are added as properties of the variable object.On entering an execution context, the properties are bound to the variable object in the following order:
文件下面還有很多落落長的敘述,有興趣可以點進去看。
undefined
undefined
白話的用一些程式表示
1 | variableObject: { |
當 test
函式被執行,執行環境建立並進入,裡面宣告的 a
會被加到變數物件內成為屬性
1 | variableObject: { |
當進入執行環境時,會把函式的參數放到變數物件上,如果未傳入參數的值,則初始化為 undefined
1 | variableObject: { |
對於函式,如果變數物件已經有同名的屬性,則取代裡面的值
1 | variableObject: { |
對於變數,如果宣告的變數已經重複,則什麼事情都不會發生
透過 ECMAScript 的文件解釋,我們可以理解原來 hoisting 背後的原理是這樣,相較於前面我們對於 hoisting 的觀察是較為表面的 (所以那個時候都說,我們可以把這一段程式碼想成 …)
但現在我們透過 ECMAScript 文件了解這些規則,接下來我們要嘗試用這一套規則來回答一開始的問題。
]]>hoisting 有順序? 抱歉我也不知道,不知道的東西就該好好記錄下來~
前篇了解基礎觀念後,接著要來了解一些之前從來沒有想過的問題。
1 | function test(){ |
宣告變數 a
之後,在宣告一個 a
的函式陳述式,結果居然會印出函式。
1 | function test(){ |
再將順序調換,仍然印出函式。
代表 hoisting 後的順序,函式的優先權是比變數高的
1
2
3
4
5
6
7 function test(){
function a(){}
var a;
console.log(a);
a = 'local';
}
test(); // ƒ a(){}
可以把原本的程式碼想像成上面這樣,這邊也有個陷阱。
我們可能會想說:「明明下一行是 var a
可是為什麼印出來的 a
不是 undefined
」
然而 JavaScript 是這樣的:
這段話的意思用程式碼表達是這樣的:
1 | var a = 10; |
回到原本的例子,就可以明白為何還是印出函式了。
同樣的例子,再度調換程式碼位置
1 | function test(){ |
如果這邊回答印出函式,那麼代表又中陷阱啦~
根據剛剛的觀念,好好的來排一下順序:
1 | function test(){ |
所以結果才會是 local
1 | function test(){ |
由此可知,同名函式的情況下,很合理的是後面蓋前面。
1 | function test(a){ |
此時相當於
1 | function test(a){ |
可知參數 hoisting 的優先權是大於變數的。
接著來測試參數與函式的優先權
1 | function test(a){ |
可知函式 hoisting 的優先權是大於參數的。
除了考慮 hoisting 順序以外,也與程式碼的執行順序有關。
所以最後再來個例子,考考自己有沒有懂:
1
2
3
4
5
6 function test(a){
console.log(a); // 123
var a = 456;
console.log(a); // 456
}
test(123);
========
1 | function test(a){ |
這一小節真的是太猛啦,根據前世的記憶(?),奇怪部分對於 hoisting 的補充並沒有這麼多範例,而且我也從來沒想過 hoisting 這邊可以有這麼多陷阱。
真的是非常感謝這一系列影片,覺得自己又更認識 JavaSctipt 一點。
然後,關於 hoisting 還不只這樣哦,還要繼續深~下去。
]]>hoisting (提升) 也是常常被拿出來詢問的觀念,所以我決定要好好地記錄下來,拆成多篇記錄,由淺入深。
使用一連串的範例來了解 hoisting 做了些什麼:
1 | console.log(b); // b is not defined |
因為辦法使用未宣告的變數。
但如果改成這樣子寫,就不會出錯了。
1 | console.log(a); // undefined |
這裡就是 hoisting 的一個特性,可以想像成它把所有的宣告提升到第一行,也就是說這段程式碼與下列這段程式碼的結果是一樣的:
1 | var a; |
但必須注意的是,hoisting 並不是真的物理性的把程式碼給拉到第一行。
除了變數之外,其實函式也具有 hoisting 特性,但在介紹之前,先簡單複習一下函式陳述式 (Function Statements) 與函式表示式 (Function Expressions),或者點一下[JavaScriptWeird]No.26 函式表示式與函式陳述句複習。
1 | function greet() { |
上面是函式陳述式,下面是函式表示式
1 | var anonymousGreet = function greet() { |
這邊只要知道這樣就可以了,接著來看範例。
1 | test(); |
這個範例的 hoisting 就算是 JavaScript 的初學者一定也遇過,因為 hoisting 的關係,所以允許把函式寫在呼叫該函式的下方,這在某些程式語言是做不到的,而我們也可以把這段程式想像成這樣,結果是一樣的:
1 | function test(){ |
但如果函式寫成表示式的話,就沒有提升的效果了。
1 | test(); // test is not a function |
其實原因很簡單,因為我們一開始看的範例已經得知:
只有變數宣告會被提升,賦值不會
也就是說這段程式碼可以看成這樣:
1 | var test; // undefined |
由於變數 test
的值是 undefined
,並不是函式所以無法被呼叫。
1 | var a = 'global'; |
可能會很直覺的回答變數 a
會印出 global
,因為 test
函式內並沒有看到變數 a
。
但這邊事實上會印出 undefined
,因為 hoisting 會發生在變數的 scope 裡面,也就是說我們可以想像成這樣:
1 | var a = 'global'; |
可…可惡,陷阱真多!
關於 hoisting 的基礎觀念大概是這樣,但其實 hoisting 背後還有相當深的觀念需要了解,特別推薦 Huli 大大的作品 - 我知道你懂 hoisting,可是你了解到多深?
]]>上一篇提到了在 ES6 之前變數的生存範圍,接著要提到的是於 ES6 時新增加的兩個變數宣告方式:let 與 const 。
關於變數的作用域 (scope):
而
let
與const
的作用域就是屬於以 block 來劃分的方式。
1 | function test(){ |
這個例子因為所有的程式都被包覆在 test
函式內,所以 console.log
可以取用變數 b
。
使用同一個例子當作對照組
1 | function test(){ |
原因是因為「使用 let
、 const
宣告的變數作用域只存活在 { } 中」。
也就是說這個例子中的變數 b
只存活於 if
的 {} 內 。
1 | function test(){ |
意思就是如果像這樣把變數用 let
宣告在函式內的話,其實跟使用 var
來宣告變數是一樣的。
1 | function test(){ |
看影片才知道原來可以直接使用 {} 建立一個區塊 (block),然後這邊得到的結果也與上面提到的觀念一致, {} 外嘗試取用變數 a
是沒辦法的。
不知不覺小菜雞也挑戰到第五層了,這一層樓的版面看起來也是蠻輕鬆就能搞定,所以為了給自己一點點挑戰性,我稍微添加了一點互動回饋:
接著就是寫程式的部分了,不得不說做這種跟資料相關的版面,使用 Vue 真的是輕鬆很多,只要專注在資料的處理上就好了,所以當我看到這次的題目是 AQI 儀表板,想都沒想就決定要用 Vue 寫了。
身為一個前端菜雞,菜歸菜但基本的資料蒐集能力還是要有的,我下的關鍵字是「行政院環境保護署 API」,果不其然第一筆搜尋結果就是答案。
一進網站就馬上發現目標了,空氣品質指標 AQI ,接著只要切到資料檢視頁籤,在選擇 JSON 格式就可以囉。
以下引用自 MDN 的說明:跨來源資源共用 (Cross-Origin Resource Sharing (CORS)) 是一種使用額外 HTTP 標頭令目前瀏覽網站的使用者代理取得存取其他來源 (網域) 伺服器特定資源權限的機制。當使用者代理請求一個不是目前文件來源 - 例如來自於不同網域 (domain) 、通訊協定 (protocol) 或通訊埠 (port) 的資源時,會建立一個跨來源 HTTP 請求 (cross-origin HTTP request)
白話來說,就是這支空氣品質指標的 API 沒有打開 CORS ,所以如果我們把做好的網頁成品發佈到 GitHub 、 Codepen 的話是不能撈取資料的。
我之前的作法都是使用別人建立好的服務,像是這個:
用法相當容易,只需要在後方加入要使用的 API 即可。
但是這一次,我在討論區上看到其他大神們分享這個方法,讓我躍躍欲試。
使用這個服務必須要申請一個 Google 帳號,接著我們來到雲端硬碟的畫面。
左上角有個「新增」按鈕點進去即可看到這個畫面,因為之前我已經有操作過了,如果是第一次使用要點選「連結更多應用程式」,接著於搜尋欄輸入「script」即可找到這個服務囉。
接著可以在程式碼內貼上這一段程式碼:
1 | function doGet(e){ |
接著按下發布 > 部署為網路應用程式
使用方式:部署的網址?參數名稱= API 網址
這樣子只要一次工,之後練習的作品全部都可以透過這個服務解決掉 CORS 的問題唷
如果是工作上遇到的 CORS ,可能就不適合這個方法囉。
本段圖片、程式碼引用自此,感謝大大的分享。
這方式也是有蠻多種的,如果想方便一點可以使用 axios 這個非常強大的套件,而且討論區中也非常多人使用,而且它本身也有 Promise 的功能了。
用法相當簡單,像是這樣即可:
1 | axios({ |
更詳細可以參考 axios 的官方說明。
雖然說這樣就可以了,不過因為我不懂 Promise 是什麼,該如何與 Ajax 結合取得資料,所以決定自己動手做看看,用土法煉鋼的方式(?
首先因為我完全不懂,所以直接上網 google 了一下:
所以大概對於 Promise 有一點點的概念,總之就大概長得像這樣?
1 | let promise = new Promise((resolve, reject) => { |
大概就是理解成,建立一個 Promise 然後可以用兩個函式 resolve 、 reject 分別代表兌現或是失敗。
resolve()
,接著程式會運行 .then
的部分reject()
,接著程式會運行 .catch
的部分然後搭配 XMLHttpRequest()
應該就沒問題了
並將程式碼修改如下:
1 | let promise = new Promise((resolve, reject) => { |
這樣就完成了呢,不過這邊有個小小的問題,就是用 XMLHttpRequest()
取到的結果會是字串,要額外透過 JSON.parse()
轉成 JSON 才可以使用。
所以結論就是 axios 好用
處理好 API 的問題後,再來就要實作內容了,這個部份很基本,不過我卻每次都會忘記該怎麼處理,所以決定這一次把它寫下來。
而取出陣列中不重複的值做法有很多種,我習慣用 filter()
就是了。
基本的 filter()
起手式是這樣,會回傳一個新陣列:
1 | let arr = ['apple', 'banana', 'lemon', 'apple', 'watermelon', 'grape']; |
而這些參數分別代表為:
item
— 當前是 arr
陣列中的哪一個值,如「 apple
」index
— 這個值在 arr
陣列中的索引,如「 apple
」的索引為 0array
— 這個陣列的內容比方說想從陣列找出 apple ,可以在 return
後補上條件:
1 | let arr = ['apple', 'banana', 'lemon', 'apple', 'watermelon', 'grape']; |
如果想找出陣列中的不重複值,則條件就複雜多了:
1 | let arr = ['apple', 'banana', 'lemon', 'apple', 'watermelon', 'grape']; |
使用了一個方法 indexOf() ,這會回傳從陣列中第一個被找到的目標索引,若不存在於陣列中則回傳 -1。
也就是說這段程式碼實際上是這麼跑的:
arr
陣列的第一個元素是 apple
,array.indexOf('apple')
的結果為 0,所以實際上會像這個樣子 0 === index
,然而目前是陣列中的第一個元素,索引是 0 ,因此結果是 true
,將 apple
加入新陣列中。array.indexOf('apple')
的結果為 0,而當前的索引是 3 ,因此結果是 false
,不加入。array.indexOf('watermelon')
的結果為 4,而當前的索引是 4,因此結果是 true
,將 watermelon
加入新陣列中。所以才能找出陣列中不重複的值,透過這樣的方式,要找出陣列中重複的值也很容易,只要 === 改成 !== 就可以了。
本篇會提到一個相當重要的觀念,那就是 Scope 作用域,也就是變數的生存範圍。在 ES6 以前 , JavaScript 的作用域界定都是以函式 function 來劃分,本小節會著重以 ES6 之前的作用域來講解。
1 | function test(){ |
毫無疑問地會輸出 10 ,但如果我們試著於函式外印出變數 a
的值呢?
1 | function test(){ // test scope |
會得到錯誤「a is not defined
」,這是因為變數 a
屬於區域變數,在函式外的 console.log(a)
無法取用變數 a
。
如何判斷一個變數到底是屬於全域變數還是區域變數?
更多的例子:
1 | var a = 20; |
test
函式內印出的 20 ,因為在 test
的作用域內沒有找到對應的變數 a
所以轉而向上一層尋找變數 a
,因此輸出為 20 。
1 | var a = 20; |
接續上例,因為在 test
的作用域找到相應的變數 a
,因此就不會繼續往外尋找。由此可知:
進階的例子:
1 | var a = 20; |
在這邊要注意的是 test
函式內並不是變數 a
,因為沒有透過 var
/ let
/ const
宣告,因此在函式內的 a
是掛載在全域 window
物件下的屬性 a
。
然而,屬性通常是可以被 delete
刪除的。
但在此情形,如果我們宣告全域變數的話,全域變數會被掛載到 window
物件下成為屬性,而且不可以被刪除。
這題的執行流程是這樣的:
a
先被賦予初始值 undefined
,此時也成為全域 window
物件下的屬性a
賦值 20test
函式,對屬性 a
賦值 10 ,第一次印出 a ,結果為 10。a
已經被賦值 10 ,因此輸出為 101 | function test(){ |
因為屬性是沒有作用域的,這麼寫相當於
var a
雖然這麼寫很方便,但是我們應該盡量避免汙染全域變數,應使用 var
/ let
/ const
宣告變數。
需要更多例子
1 | var a = 'global'; |
一個較貼近實務的範例可能會長得像這樣,雖然比較結構複雜,但是只要掌握一個原則:
延續上面的範例,如果我們把某一行註解掉:
1 | var a = 'global'; |
我們把 test
函式內的 a
註解,此時 test
函式與 inner
函式的作用域內均找不到變數 a
,因此最終會在全域作用域內找到全域變數 a
。
其尋找路徑如下:
像這樣逐層往外尋找某變數的方式,被稱為範圍鏈 (Scope Chain)
範圍鏈的判斷是以詞彙環境來決定, 指的是程式碼在整個程式中的「實際位置」,像是下面的例子:
1 | var a = 'global'; |
像這樣,雖然看起來我們在 change 函式內宣告了變數 a 也在裡面呼叫了函式 test ,但是實際上, test 函式的詞彙環境並沒有包在 change 函式內,因此它的範圍鏈仍然是這樣的:
另外在 change 函式內呼叫函式 test 絕對不會是這個樣子:
1 | var a = 'global'; |
1 | var a = 'global'; |
則應該改變 test
函式的詞彙環境。
因為範圍鏈是以詞彙環境、函式被宣告在哪裡來決定的,並不會因為在哪裡被呼叫而改變範圍鏈。
以上就是 ES6 之前對於 scope 的概念,下一篇將記錄 ES6 之後對於 scope 有什麼新觀念要了解。
本篇用到了相當多的範例來解釋不同作用域下輸出的值會是多少,比起「奇怪部分」來說,「奇怪部分」在這邊的解釋是使用一張大圖,搭配一個例子來解釋整個作用域與範圍鏈,而這邊是採用類似這樣的方式。
這麼做還蠻有用的,比起只看圖而言,透過這樣子寫出來也容易加深自己的印象!
]]>let 與 const 是 ES6 之後才加入的宣告變數的方式,宣告出來的變數表現也不太相同,這篇記錄會比較著墨於 const 的表現上。
首先必須要先了解什麼是變數,這將有助於了解後續相關知識。
var
/ let
/ const
宣告的才算是變數var
建立一個全域變數 a
,這時的變數 a
會被掛到 window
物件下成為屬性,並且無法被刪除。一些關於變數、屬性的比較可以看初次參加保哥的 JavaScript 開發實戰:核心概念篇 感想或者是看當天上課的簡報。
這兩者的行為是比較接近的,但在範圍 (Scope) 以及一些表現上有點不同,之後會有比較詳細的介紹,或者是[JavaScriptWeird]No.10 範圍與 let,如果不想思考的話,結論就是「從現在開始都用 let 取代 var 」。
const
就是常數的意思,使用的時候必須直接賦予一個初始值,而且之後不能透過任何方式改變這個值,也無法重複宣告。
1 | const a; |
上面這個範例不能運作。
const
使用的時候必須直接賦予一個初始值因此必須像下面這個例子一樣。
1 | const a = 10; |
const
使用的時候無法重複宣告
1 | const a = 10; |
使用 const
的話,不能透過任何方式改變變數的值,而這個「值」的意思其實就是說「不能改變變數指向的記憶體位址」,讓我們畫圖理解。
1 | const a = 10; |
在第一行,建立一個原始型別 (number) 的物件並且令使用 const
建立的變數 a
指向它。
第二行,同樣建立一個原始型別的物件,並且試圖令變數 a
指向它,如紅色箭頭所示,因為這個變數 a
是使用 const
宣告的,所以無法改變變數指向的記憶體位址。
透過上面的例子,對於使用 const
宣告的變數有了初步的了解,接下來比較進階的例子。
1 | const obj = { |
接著將例子做一點小調整,為什麼這裡的 obj.number
可以被修改呢?
const
只是「不能改變變數指向的記憶體位址」,而這個範例並沒有改變 obj
變數的指向,只有改變 obj
物件的 number
屬性指向的記憶體位址。到這邊,如果我們能正確地畫出這個圖,相信這邊的觀念已經了解囉。
至此已經跑完第一部分的影片了,針對每個小節我都有寫一點點心得,當完成一個段落時,我發現小節與小節其實都是有相關性的。
而與其他課程不同的是,通常如果提到原始型別 (Primitive Type) 與物件型別 (Object Type) 的差異,可能會一次就把差異全部講完。
但在這邊則是把這些差異拆分出去到子小節內,透過不同的範例來解釋這些差異,我覺得這是很實用的。
人的專注力曲線大概是長這個樣子:
專注力不會一直都是維持最高的狀態,也就是學生會在影片一開始時專注力最高,接著在上的正精彩的時候掉到谷底,然後發現影片快結束了又回到最高。
也就是說,如果一支影片只講一個觀念,透過範例的方式就很好吸收,而且也比較容易控制影片的長度。
]]>直接講結論:「不管什麼時候,都應該只使用三個等於來進行比較,除非是進行教學或者是-故意的。」
首先我們必須了解「==」實際上在做什麼,這部分的觀念其實在[JavaScriptWeird]No.16 強制型轉、[JavaScriptWeird]No.17 比較運算子都有提到一些。
使用「==」進行比較時,「==」會嘗試將左右兩邊的型別根據某個規則,嘗試將型別轉成一致並進行比較,最後回傳比較結果。
1 | var a = 1; |
如果使用「===」進行比較,就不會觸發型別轉換,因此結果會比較接近我們預期的那樣。
1 | var a = 1; |
可以上 JS Comparison Table 查詢各種比較結果。
因為使用「==」會觸發型別轉換,而且透過雙等號比較表可以得知其中規則相當複雜,可能會跑出一些非預期的結果。
而使用「===」的話,因為不會觸發型別轉換,較容易預測結果,而且比較表也相對好記憶。
之前提到一些原始型別 (Primitive Types) 與物件型別 (Object Types) 的差異,這邊將提到另外一個差異:
1 | var obj = { number: 1 } |
眼尖的你應該會發現,比較表上明明寫著兩個物件相比都是 false ,那麼為什麼這邊會是 true 呢?
我們可以把這段 code 在記憶體的樣子畫出來:
可以發現變數 obj
與 obj2
都是指向同一個記憶體位址,因此第三行的比較其實是「比較兩個變數指向的記憶體位址」,既然兩個變數都指向同一個記憶體位址,那麼答案自然會是 true 。
同理,修改一下例子,並畫圖了解:
1 | var obj = { number: 1 } |
由前一個例子可知,物件型別之間的比較比的是記憶體的位址,從這張圖看來,兩個 {} 物件在記憶體中的位置並不相同,所以比較的結果才會是 false。
這樣就可驗證為什麼只要是物件型別之間的比較都會是 false 了,因為只要產生一個新物件,就會是不同的記憶體位址。
只要不屬於原始型別的那六種,都是物件型別。這倒是可以用很簡單的二分法來判斷。
顧名思義 NaN 就是非數字(Not a Number),但是很弔詭的是:
使用 typeof NaN
得到的結果會是數字型別 (number)
1 | console.log(typeof NaN); // number |
而 NaN 本身也無法使用「===」或「==」與自己比較
1 | console.log(NaN === NaN); // false |
神奇的是如果你拿數字型別跟 NaN 相比也會是 false
1 | console.log(typeof(1) === NaN); // false |
相當容易產生,只要 Javascript 發生自動型轉或者我們主動將值轉換成數字時,當中混有無法轉成數值的東西時,就會產生 NaN。
因此 NaN 是個相當可怕的東西,我們應該避免產生 NaN。
因為我們沒辦法從上面任何方式判斷是否為 NaN ,但是幸好還有 isNaN() 可以使用,使用方法相當容易:
1 | var test = 1 + 's'; |
這樣子就能判斷是否為 NaN 了,另外這是 MDN 對於 NaN 的解釋
很不幸的 isNaN()
好像不是所有瀏覽器都支援,但是我們可以透過 polyfill 令不支援 isNaN()
的瀏覽器也可以使用。
而幸運的是,這可以在 MDN 上找到。
感恩 MDN 讚嘆 MDN
這是上完保哥課程後的第一篇記錄文,很高興能用到課堂上的知識來加深這些觀念的印象,而最棒的是我看的這支影片居然也用同樣的方式來介紹這些觀念,果然是高手所見略同呢。
我覺得對初心者來說,比起生硬的文字,最容易有印象的果然還是畫圖。
而且是必須要親自畫,不能只有看。而從「讀出程式碼」到畫出來的過程中,也可以了解自己觀念理解的正不正確,因為只要唸出來的是錯的,代表你想的也是錯的,自然畫出來的圖也會是錯的,這時候就會產生BUG。
]]>這一層樓切版算容易,JavaScript 部分也不困難,我認為這一層樓要點在於「資料蒐集」,也就是當遇到不知道怎麼寫的時候,如何靠自己的力量找到答案。
因為這層樓只要找到 toLocaleString()
方法,並了解使用方式、有什麼參數可以使用,那麼這一題基本上就解完了。
當然這題還有其他解法,只是我認為使用 toLocaleString()
很方便,只要代入想要的時間格式、地區,就可以拿到資料了,省去蠻多計算的。
我的想法滿簡單的,因為是關於時間的處理,自然而然想到 Date()
,但我不清楚裡面有什麼方法可以幫助我,畢竟要做的是時區的轉換,從先前的經驗得知 MDN 可能會有我要的東西,於是關鍵字是這麼下的「JS Date() MDN」。
第一筆就是我要的結果,點開可以看到 MDN 對於 Date 物件的描述:
Date
物件提供了若干 UTC (通用的) 以及本地時間方法。UTC,也被稱為格林威治標準時間(GMT)被指定作為世界時間的標準。本地時間指的是被設定在執行 JavaScript 電腦上的時間。基本上 MDN 就能得到相當充足的資訊,接著繼續往下看,可以知道更多掛在 Date 物件下的方法,最後找到
就是這個了,還很佛心的附有範例呢 !
但是還沒結束,因為根據 MDN 上的用法:
1 | dateObj.toLocaleString(\[locales\[, options\]\]) |
需要填入一些參數像是 locales 以及可選的 options, locales
需要的參數可以參考這篇。
整合資料後,我得出這個結果:
1 | let d = new Date(); |
zh-TW
代表想使用的時間格式, timeZone
後面接著目標時區。
如果想要以同樣的格式指定取得澳洲雪梨的時間可以怎麼做?
1 | d.toLocaleString('zh-TW', { timeZone: 'Australia/Sydney', hour12: false }); |
基本上知道這些,這題已經快做完了,剩下就只要做一些字串處理就好了,最後補上 setInterval
就達成了。
其實從 MDN 上的這一段敘述就可略知一二,世界標準時間(UTC)也被稱為格林威治標準時間(GMT)被指定作為世界時間的標準。然而如果需要更精確的解釋則可以參考世界協調時間、到底是 GMT+8 還是 UTC+8 ?。
可以透過這一篇有趣的漫畫了解,於 JavaScript 中的使用方式是這樣的:
1 | let `timestamp =` Math.floor(Date.now() / 1000); |
1 | let time = new Date(1550577930 \* 1000); |
2019/02/17 這是我嘗試投入前端領域的初次實體課程,老實說我本來是不太想參加的,畢竟大家也都看到價格了,對一個有經濟壓力的人來說,那不會是個太容易被接受的數字。
4000 對每個人來說有不同的解讀,或許對有些人來說「光講師是保哥就超過 4000 的價值了」,但對一個連業界的大神有誰、同時又是個前端菜雞、更遑論有經濟壓力的人來說,猶豫不決想必也是人之常情吧?
對,我很想參加。
但我也捨不得那 4000 ,或許是冥冥之中有注定?我一個在台北的朋友也參加了一模一樣的課程,並且也向我推坑。
我也趁勢向友人請教到底值不值 4000,他回應「以前腦補太多 JavaScript 的觀念了,如果想投入前端 JavaScript 必不可少,報名課程然後享受被打臉吧」(??
洧杰老師也於 Line 上鼓勵我要報名這次的課程,也表明自身亦參加多次,每次都有不同的收穫,後來牙一咬就去了。
先說說我的 JavaScript 程度,我的程度不高,大概就是看完六角的《 JavaScript 入門篇 - 學徒試煉》、以及看了約 70% 進度的《克服 JS 的奇怪部分》
如果要建議至少要到什麼程度才可以參加,就我自己的感覺來說,注意喔僅代表我自己喔,那大概就是看完學徒試煉的前 8 章對 JavaScript 有初步了解就可以了。
這個意思並不是說這堂課很淺,而是我上完後的感受是,這堂課比較像是重新建立我們對 JavaScript 的一些基礎的解讀
而很多人會因為這些東西太基礎而忽略、或者是因為曾經接觸過其他程式語言而自行腦補這些基礎觀念。
保哥於開課前是這麼說的「這堂課很特別,不管是初學也好、較為進階的也好,這堂課會因為自身在不同時期下對 JavaScript 的認知而有不同的收穫」
因為這是寫於課後的,當時的我沒有完全做到覺得很可惜,但是可以寫給未來的我以及可能看到這篇文章的路人甲,讓這堂課的價值遠超 4000 ,那就是:
雖然這個過程很耗費時間,導致當天課程的下半段好像比較趕,但整體來說,得到的感受仍是好的,也因為這樣的感受讓我認為花這個錢很值得。
我想保哥也是認為打好基礎很重要,否則也不會特地花這麼多時間請同學上去畫圖並指導釐清觀念,而我就是其中一個幸運兒。
這樣的實體課程還有一個好處,散場結束時,利用時間向保哥提出了一個問題:「像這樣的課程,雖然當下同學們好像都會了,但是過沒多久就會忘光,如何把這些觀念變成長期記憶呢?」。
相較於報名前的猶豫不決,或許這麼說是誇張了點,但我現在還是有「啊!有參加真是太好了」的感嘆,雖然我還是對 4000 的失去耿耿於懷,但對於結果而言,這算是筆相當划算的交易,我獲得的遠比想像中的還要多。
「慘了,今天被這堂課打臉這麼多 JavaScript 觀念,那我之前寫的奇怪部分的紀錄文好像有部分都是錯的,我是不是要回頭去改呢,可是那超多篇的欸!」
「其實這些都是過程,那些紀錄文也代表了你在那個階段的想法,在老師自己的部落格上也有很多篇文章,即使現在觀念更好了也不盡然會回頭去一篇篇改那些文章,畢竟真的是太多了。可以把那些文章留著當對照組,期許之後可以寫出品質更好的紀錄文」
感想的部分就到以上結束。
接下來的是課堂上「我還記得」的筆記,可能比較片段也比較雜亂,不過自己看得懂就好啦。
我很想記得課堂上保哥講的每一個觀念,不過人畢竟不是錄音機,就…能吸收多少就算多少囉。
例如取用 car
物件下的 001
屬性
car.001
,會得到 Error,因為 .001
是不合法的識別符號。car.['001'];
delete
刪除delete
刪除var
/ let
/ const
宣告的才能算變數var
建立一個全域變數 a
,這時的變數 a
會被掛到 window
物件下成為屬性,並且無法被刪除。1 | var a = 1; |
1 | b = 2; |
JavaScript 的 Number 在一定位數以上的數字後就會失準,並轉變成科學符號,因此判斷最大安全整數非常重要。
物件、變數與型別之間的關係
1 | var a; |
上述四行程式碼在執行的過程中,請問:
1 | var a = {x:1}; |
請問 b.x 、 a.x 結果為何?
讀懂每一行程式碼並畫圖了解實際運作
利用閉包的特性,實作出每呼叫一次就 +1 的計數器
1 | function MyFunc() { |
請利用 JS 閉包 (Closure) 特性實作私有變數的 get 與 set 存取子
1 | function MyFunc() { |
上次提到 Primitive Types 與 object 的其中一個差別,接下來我們討論另一個差別,那就是對於運算子「=」,兩者的表現並不一樣。
賦值就像字面上的意思一樣,重新給予一個值,而我們也可以喚醒另外一個世界線的記憶:
這也達到了當初做筆記的目的,因為有之前的筆記,所以我在學習相同的觀念但不同講法時,我可以交叉驗證自己吸收得如何。
1 | var a = 10; |
上面這個如我們預期,因為 Primitive Types 是傳值的,我們創造一個新的變數 a
,並且令 b = a
,變數 b
會指向一個新的記憶體位址,並拷貝那個純值 10 ,放到新的記憶體位址。
接下來做點改變
1 | var obj = { |
首先使用物件實體語法建立了 obj
物件,接著使用等號運算子將右邊的 obj
給 obj2
,而物件是傳參考的,也就是說這兩個物件目前指向的記憶體位址相同,所以結果才會是這樣。
上面算是一個小複習,畢竟在另個世界線有同樣的範例了。
1 | var arr1 = []; |
以上就是 Primitive Types 與 object 的另一個差別。
延續上面那個物件的例子,如果改成這樣:
1 | var obj = { |
雖然上面提到物件是傳參考的,但是在這個例子中雖然 obj2
與 obj
一度指向同樣的記憶體位址,但是隨即又被等號運算子重新賦與一個新的物件,此時 obj
就與 obj2
指向不同的記憶體位址了。
透過上下兩個相似例子的比較,我們需要搞清楚以下:
obj2.num = 20;
obj2 = { num: 20 };
雖然都是使用等號運算子,但是意義上是不太一樣的。
回想一下第一點的那個例子,
obj
與 obj2
指向同一個位址obj2
內的屬性 num
obj2
,所以沒有影響到 obj2
的位址。第二點的例子,
obj
與 obj2
指向同一個位址obj2
obj2
,等號運算子把新物件的記憶體位址給了 obj2
也就是說,此時 obj
與 obj2
就不相干了。
另外還有個初學者會遇到的例子,關於等號運算子的,也就是少打等號的問題,一般我們都會這樣寫
1 | var a = 1; |
但如果少打了等號:
1 | var a = 1; |
結果就會出現非預期的輸出,原因是這樣子寫就如同:
1 | var a = 1; |
所以這個 if 內的東西一定會執行。
這個小節的內容個各別散落在原本世界線的不同文章上,透過這樣的重新整理,又再次的複習了這些概念,其中我最喜歡的是小陷阱的部分,可以藉由這些陷阱,再度的仔細思考是不是哪個環節想錯了。
我開始喜歡寫紀錄文了,這有點像鄧不利多的儲思盆,可以把記不住的東西給寫下來,然後不占據腦容量,需要的時候在到儲思盆看一下~
]]>雖然暫時沒辦法全部都記住,看久、摸久、寫久總會是我的。
因緣際會之下,我得到了更多學習 JavaScript 的機會,覺得這很適合成為 JavaScript Weird 的一個支線,大概就很像打遊戲一樣,同樣的關卡還有個裏關。因此所謂的支線呢,就是把之前篇章提到的某些概念再拿出來寫一次,但是記錄的觀點不一樣,希望透過這樣的方式,能讓自己理解更多。
變數的資料型態總共有七種:
JavaScript 有六種純值 (Primitive Types,或稱為基本型別、原始型態)
其他都是物件
最簡單的方式就是使用「typeof」,先把每個型態都印出來瞧瞧:
1 | console.log(typeof 10); // number |
這邊故意用了一行空白隔開,上半部是比較好理解的,因為那些的確就是它們的型態,可是下面的部分呢?
為什麼陣列的型態會是物件 ?
1 | var a = []; |
可以像這樣透過觀察原型的方式得知, array 的最底層也是物件。
同理也可以觀察函式,會得出函式也是物件。
最特別的是 null ,為什麼 null 的型別會是 object ?
其實這是 JavaScript 最廣為人知的 BUG ,MDN 上也有相關的記載,聽說是從一開始就存在了,但可能是有牽扯到很多東西,所以沒有人去修正。
MDN 也幫我們整理好了 typeof
的表格,真的是非常給力。
雖然大多數的情況, typeof
已經夠用了,但如果我希望如果它的型態就是 array 而不是回傳 object 的話,還有什麼方式?
1 | var a = []; |
1 | var a = []; |
還記得運算子的優先性與相依性嗎?typeof 是運算子,查詢表格後得知是右相依性 (right-to-left),常見的做法像是「檢查某變數有無作用」:
1 | if (typeof a !== 'undefined') { |
透過這樣子寫,當變數 a
不存在的時候什麼都不會發生,存在的時候就會秀出該變數型態。
但問題是為什麼不能直接用「!==」判斷就好 ?
為什麼 typeof
後面可以直接用 a
?
1 | // var a; 沒有這行就出錯 |
就如同先前培養的觀念,如果沒有宣告變數 a
,在全域執行環境內也找不到 a
,這時如果嘗試取用 a
就會跳出錯誤。
然而,為什麼 typeof 就可以呢?
讓我們再次研究運算子的優先性與相依性
優先值高的會先執行,也就是說實際上是「typeof a
」先做了。
typeof
運算子會傳回一個字串值,指出未經運算 (unevaluated) 的運算元所代表的型別,之後才進行「!==」的比較。
其中的一個差別就是,Primitive Types 是不可改變 (Immutable) 的,這個「不可改變」不是直觀的那個意思。
1 | // 並不是這個意思 |
這個並不是改變,正確來說這樣叫重新賦值。
所謂的 Primitive Types 不可改變是這樣的:
1 | var str = 'hello'; |
現在 str
是個字串純值,使用了 toUpperCase()
把小寫字母變成大寫字母,但因為純值本身是不可改變的,所以當執行完 toUpperCase()
後, str
本身並沒有改變什麼,所以仍舊是小寫的 hello 。
而「toUpperCase()
」 會做的事情就是取得 str
的字串值後,「回傳」一個大寫的新字串,而不是改變 str
內容。
當然如果改成這樣寫就變成大寫了,因為被重新賦值了。
1 | var str = 'hello'; |
那至於可改變 (mutable) 的 object 是怎麼回事?
1 | var arr = [1]; |
陣列 arr
本身的確被改變了,而且不是藉由等號運算子賦值。
正如我前言所說的,覺得這真得很適合做為 JavaScript Weird 的支線,因為講到的觀念其實都蠻進階的,可以看到我時不時穿插了之前做的筆記,但畢竟這算是給初學者的進階 JavaScript ,所以也算是蠻合理的。
]]>我們已經知道了如何使用函式建構子建立物件的方法,但除了透過函式建構子之外其實還有別的方法可以建立物件且指定原型。
Object.create() ,MDN 的說明為指定其原型物件與屬性,創建一個新物件,直接透過範例來了解:
1 | var person = { |
我們建立物件 person
,並且使用 Object.create()
方法建立新的物件,把 person
當成參數傳入,接著印出 john
。
我們並沒有改變什麼,只是說有其他種方式可以建立物件。
john
現在是一個空物件,且原型是 person
物件,因此可以呼叫 greet
方法。
1 | console.log(john.greet()); |
因為 john
現在是空物件,所以它的 firstName
會到原型鏈上尋找,因此我們只要讓 john
有對應的屬性就可以了,像這樣:
1 | var person = { |
簡單來說我們只是建立一個物件,並且以這個物件為基底,在這物件之上建立了新物件,然後我們就可以在這之上進行覆寫、增加屬性或方法。
這麼做還有一個好處,就是原型物件非常直觀就可以進行修改,而且以該物件為原型的其他物件都可以取用這些方法,這就是純粹的原型繼承。
1 | var person = { |
結構略有不同,個人較偏好使用 Object.create()
建立原型物件。
Object.create()
這麼神奇好用,是不是可以廣泛應用在任何地方?很遺憾,雖然大部分瀏覽器都支援這麼方法,但如果需要支援到 IE8 的話這是沒有辦法使用的,但也有相對應的措施,那就是找找看有沒有 Polyfill 。
Polyfill 是將引擎缺少的功能增加到程式中,使之可以正常運作,所以如果我們想要在 IE8 或者更舊的環境下使用 Object.create()
可以這麼做:
1 | // Polyfill |
MDN 很貼心的將 Object.create()
的 Polyfill 放上去了,所以可以像這樣直接貼上到程式碼內,MDN 其實提共了不少現成的 Polyfill,讓我們方便使用。
同樣的方式我們可以再新增一個 mary
物件,並且設定原型為 john
然後觀察輸出情形:
1 | var person = { |
觀察後發現 mary
物件的原型鏈的下一層就是 john
,最底層是 person
,因此也可以使用 greet
、 sayHi
方法。
for in
我想並不陌生,因為在 Reflection 與 Extend 時有用它來遍歷物件並且印出來,然而前面我們也提到過,陣列也是物件,所以也可以在原型上加入新的屬性或方法,本篇是用來介紹關於兩者如果一起用可能會導致的問題。
1 | var arr = ['John', 'Mary', 'Tom']; |
雖然很奇怪但這是合理的結果,因為陣列也是物件。
變數 item
中的 0、1、2 其實是名稱屬性,所以可以使用中括號取用它們,而 John、Mary、Tom
是值。我們使用 for in
遍歷了整個陣列並且印出。
接著我們增加屬性到它的原型上。
1 | Array.prototype.myNewAdd = 'cool!'; |
結果居然多跑出一行我們新增到原型上的屬性,為什麼?
因為陣列也是物件,而 for in
會找到原型鏈上,把原型上的屬性、方法一起遍歷,所以再一些不需要 for in
的情況中可以使用 for
迴圈即可,像是這樣:
1 | for(var i = 0; i < arr.length; i++){ |
接續前篇,另一個危險小叮嚀時間,我們已經討論過內建的函式建構子,它們相當的便利簡潔,但也很危險。
看出問題在哪邊了嗎?
a
是數值 3 ,而 b
是物件。但實際上兩者是完全不同的東西,三等號運算子回傳的結果才是正確的。
內建的函式建構子所建立的純值並不是真正的純值,因此如果要建立純值就使用實體語法建立即可,除非我們很確定須要用上函式建構子建立。
前面提到 new
後面接著的是一般的函式,所以有沒有 new
的差距相當大,例如沒有 new
的情況,我們可以使用這些內建函式來轉換型別,此時的 c
就是一個真正的純值。
現在我們已經知道函式建構子了, JavaScript 有一些內建的函式建構子,可以在這篇討論一下,而這些內建的函式建構子大多也都遵循傳統首字母大寫。
首先打開 chrome 的開發者工具,不需要自行撰寫程式碼,因為有些函式以及函式原型已經存在,所以可以直接測試。
Number()
是 JavaScript 內建的函式建構子,遵循了首字母大寫的傳統,這邊要注意的是 a
並不是一個純數值,而是一個物件裡面包著純值 3 。
因為 Number()
是一個物件,所以它有原型,順著觀察下去:
可以看到有許多的方法,所以 Number
物件都可以取用到這些方法,像是裡面的 toFixed()
,因此可以這麼用:
試試另一個內建的函式建構子 String
用同樣的做法讓 「a.
」能夠取用到一堆在字串上能用的方法。
這些方法並不再 a
上,而是在 String
的原型上。舉例來說,像是:
我使用了 .indexOf("J")
尋找字串中是否有這個字母,回傳 0 代表 J
在第 0 個位址。
要特別注意的是,這裡的 a 並不是字串,這與上一個範例相同,a 是一個物件。
上面的兩個例子看起來起手都像是在建立純值,但其實我們是在建立一個物件,而這個物件包含了純值以及一些額外功能。
另外某些例子中 JavaScript 允許這樣子做,它會知道我們要物件而不是純值:
「.」前面的 John
是個純值,但 JavaScript 自動把它放入字串物件中,讓我們能使用 length
方法。
就類似於這樣:
1 | new String("John") |
然後再對它做 length
方法。
但並不是所有內建的函式建構子都能這麼做,像是:
在有了原型、原型鏈、函式建構子的觀念後,我們甚至可以在內建的函式建構子上額外擴充一些方法,像是增加一個方法給所有字串:
1 | String.prototype.isLengthGreaterThan = function(limit) { |
純值字串 John
被字串原型函式轉換成字串物件,並且取用了我們新增於字串原型上的 isLengthGreaterThan
方法。
而且之後所有的字串都可以取用這個方法,這是相當強大的!
嘗試對 Number
增加一個方法:
1 | Number.prototype.isPositive = function() { |
如同上面的範例, JavaScript
雖然會幫我們轉換字串但不會轉換數值,所以仍然要使用函式建構子,然後呼叫我們新增的 isPositive
方法。
如果要處理這些被函式建構子所建立的東西,觀念不清楚是相當危險的,畢竟它們很像純值但實際上是物件。
下一篇要來了解為什麼危險以及正確的觀念為何?
]]>還記得函式建構子是如何運作的嗎?當執行環境執行時, this
變數指向空物件,如果不回傳任何東西的話,預設回傳新物件。而當我們用函式建構子時,它們仍然是一般的函式,只是在前面加上 new
運算子,然而危險的部分也在這裡。
1 | function person(firstName, lastName){ |
如果把 new
運算子拿掉會如何呢?
當我們忘記放上 new
關鍵字時,後面跟著的是一般的無回傳函式,所以運行後沒有回傳任何東西給變數,因此變數的值為 undefined
。
當取用 getFormalFullName
方法時,因為此時 john
並非物件,而是 undefined
,所以沒辦法到原型鏈上尋找方法,因此跳出錯誤。
課程中講師有提到似乎有一些約定成俗的習慣,就是:
任何要做為函式建構子的函式,首字母大寫
這樣子就能馬上認出哪個地方少了 new
運算子,或者是使用 Linters 輔助撰寫程式碼。
這一層樓的網頁切版相對的簡單很多,困難的點在於程式邏輯上
然而在這一次的地下城中,學到了 eval() 與 正規表達式的用法:
eval() 在本次地下城中,主要用來處理已經轉換成字串的算式部分,舉例來說像是這樣:
1 | var str = '100 - 50'; |
正規表達式主要用於加入千分號、驗證上,本次只有知道該如何使用正規表達式在程式上,至於規則的撰寫並沒有太過深入,可以像這樣用在需要驗證的地方:
1 | let key = '00'; |
像這樣,我寫了一個規則,檢查字串開頭是否包含「00」或「.」,對於正規表達式可以使用 .test()
來驗證,還有更多使用方式可以參考 MDN
然後還有計算機上方那一排小字,就是記錄著已經輸入哪些算式的地方,有些人的做法是「如果過長就刪節號省略」、有些人的做法是「客制化 Scroll bar 使之可滾動」。
但我的做法是參考 win7 內的小算盤,電腦內的小算盤再算式過長時,會有一 種被往前推的效果 (請原諒我不會描述) ,一開始我也不知道這是什麼效果、要怎麼寫,但後來我是使用 substring () 達成。
我設定了固定的字元數,如果公式長度超出了字元數,那麼就兩者相減,多出來的字元數就填入 substring () 當成起點。
上一篇看到了函式建構子能夠幫新物件設定屬性和方法,接下來要介紹如何使用函式建構子設定原型,是另一個 JavaScript 建立物件的重要部分。
1 | function person(firstName, lastName){ |
問題是,應該要如何設定原型呢?首先觀察一下 john
、 joan
的 __proto__
指向哪邊。
當使用函式建構子時,函式建構子已經幫我們設定好原型了。
建立函式物件時有個特殊屬性像是名稱屬性、程式屬性,還有每個函式都具有的原型屬性 (prototype property),除非將函式做為函式建構子使用,否則原型屬性永遠不會用到,使用 new
運算子並且使用函式建構子來建立物件時,原型屬性才有作用。
1 | function person(firstName, lastName){ |
「.prototype」指的是函式的原型屬性,所謂函式的原型屬性其實就是用函式建構子建立的物件其原型鏈指向的東西。
也就是說 john
、 joan
都指向 person.prototype
為原型,它們都可以取用到我增加的 getFullName
方法,甚至可以直接在瀏覽器直接使用。
1 | function person(firstName, lastName){ |
後面才新增在原型上的方法,仍然可以被先前就建立好的物件取用。
這代表之後所有使用 preson
函式建立的物件,都可以取用我們新增在原型上的方法,藉由「.prototype
」這個函式的原型屬性。
如果把 getFullName
方法放到 person
函式內,與把 getFullName
方法放到 person
函式的原型屬性內差別在哪?
1 | function person(firstName, lastName){ |
舉例來說,假如前者有一千個物件,那麼這一千個物件內都會新增 getFullName
方法,表示每個物件都會有自己的 getFullName
方法,這會佔據較多的記憶體空間。
而後者的話,雖然有一千個物件,但只會有一個 getFullName
方法,因為這個方法被增加到原型上。
以效能的觀點來看,將重複的屬性和方法放在原型上會比較好。
]]>至此我們已經了解物件和原型、繼承、原型鏈、物件屬性、方法等等,現在該更深入的討論建立物件。之前建立物件的方式都是使用物件實體語法建立的,然而有另一種方法可以建立物件,這是本篇要討論的內容。
一個正常的函式用來建立物件,當在呼叫函式前面放了 new
關鍵字時,在執行階段的創造階段被產生的 this
變數會指向新的空物件,當函式結束執行時,該物件會被函式自動回傳。
1 | function person(){ |
像這樣建立了一個 person
函式,接著用 new
關鍵字建立物件,並且輸出結果觀察。
居然一個物件就建立好了,我們來看看發生了什麼事。
在這個例子,我們只是用不同的方式在 JavaScript 中建立物件,為了建立物件,我們需要給這個物件屬性和方法以及設定原型,在前面幾篇文章我們都用錯誤的方式在設定原型,那只是為了好理解原型是如何運作的。
可以在裡面找到 new 這個關鍵字。當使用 new
時一個空物件被建立,就好比直接用物件實體語法這樣寫:
1 | var a = {}; |
接著運算子 new
呼叫函式 person
,當函式被呼叫時執行環境會產生 this
,然而 new
會改變 this
的指向到產生的空物件,於是我們寫的程式屬性被執行後, firstName
、 lastName
屬性被增加到空物件上。
使用 new
運算子的函式並不會回傳值,因為 JavaScript 會回傳被 new
運算子建立的物件,並且會呼叫這個函式:
1 | function person(){ |
如果不作任何事情,這個 this
會是什麼?
1 | function person(){ |
此時的 this
會指向 person
空物件,後來我們增加屬性,於是空物件內多了屬性。
我們說使用 new
運算子的函式並不會回傳值,如果主動回傳值的話會發生什麼事情呢?
1 | function person(){ |
於是 new
運算子便會回傳我們寫的 return
內容,但如果不回傳任何東西,JavaScript 會知道要建立一個新物件。
1 | function person(){ |
像這樣,即可建立多個相同屬性方法的物件,但可以再做一些調整
1 | function person(firstName, lastName){ |
new
後面接的是函式所以可以傳入參數,因此可以設定不同的 firstName
、 lastName
。
這門課程推出的時候 ES6 還未問世,現在可以看到許多的框架都有 Extend 的概念,甚至 ES6 也有 Extends ,而本小節課程作者要用 underscore.js ,利用一個叫做 Reflection 的概念達成 Extend。
Reflection 意思就是一個物件可以列出自身的屬性,並且改變當中的屬性、方法。而 JavaScript 物件有能力可以看自己的屬性和方法,而我們可以透過這樣達成一個很有用的模式,稱為 Extend (擴展、繼承) 。
讓我們先準備好一些程式碼,並引入 underscore.js
1 | var person = { |
首先使用 for in ( MDN 介紹) 迴圈,遍歷 john 物件內所有成員並且印出,讓我們看看現在 john 物件內有什麼東西。
因為 for in
迴圈也會到原型上取用屬性和方法,因此不侷限於物件本身。
也可以透過 if 結合 hasOwnProperty 進一步篩選結果
1 | for(var prop in john){ |
這樣就可以單純取出在 john
物件上的屬性或是方法,如果不在自身物件上,就是在原型上、或者根本不在原型鏈上的任何地方。
這樣的概念可以讓我們做更多的事 - 補足原型繼承,接下來課程作者就使用 underscore 內的 extend
函式作為範例介紹, extend
可以將其他物件的屬性、方法新增給目標物件。
1 | var person = { |
這邊新增的 jane
、 jim
物件,並沒有將它們放入原型鏈,但 john
又想要能有它們的屬性或方法,於是可以這麼做:
1 | _.extend(john, jane, jim); |
如此一來 john
就有了 jane
、 jim
內的方法,而且原型 person
也還在。打開 underscore.js 觀察 extend
函式是如何處理這一塊的。
可以看到 createAssigner
是利用閉包的特性撰寫而成,而使用 extend
後,物件 john
被新增了另外兩個物件 jane
、 jim
的屬性與方法。
透過 extend
函式現在 john
可以使用原本不屬於自身的屬性以及方法了。
1 | console.log(john.getFirstName()); // John |
上一篇了解物件原型與原型鏈,接著我們透過一些簡單的驗證可以了解到 JavaScript 的所有東西都是物件或純值, JavaScript 的所有東西像是數值、字串、布林、函式、陣列、一般物件這些都有原型,除了 JavaScript 的基本物件 (base object)。
首先建立一些東西方便我們做驗證:
1 | var a = {}; |
分別建立物件、函式、陣列,透過這三個東西來觀察各自的原型,接著使用 chrome 瀏覽器來觀察:
a
的原型是 JavaScript 的基本物件 (base object),基本物件在原型鏈上是相當底層的,因為所有東西的底層都會是基本物件或純值。 a
的基本物件下掛載了許多的屬性以及方法,還記得前一篇提到的,物件上的屬性以及方法是如何透過原型鏈取得的嗎?這也是為什麼我們沒有寫那些方法卻可以使用,這是因為 JavaScript 已經幫我們寫好在原型上了。b
的原型是函式,這是所有函式的原型,可以在裡面找到先前提到的 call、bind、apply
方法,這也是為什麼之前說這些方法所有的函式都可以使用的緣故。c
的原型是個空陣列,也可以看到裡面掛載了許多的方法,像是 filter
、 push
等等,意思就是我們建立的所有陣列都可以使用這裡面的方法,因為這些方法被設定在陣列的原型上。透過上面的小測試我們知道各自的原型並不直接是物件,但如果原型的原型呢?
a
第一層的原型已經是最小單位(基本物件)了,所以回傳 null
。b
的第一層原型則是函式,第二層原型則是回傳基本物件。c
也是回傳基本物件。這也驗證了開頭的標題,所有東西的原型都是物件或純值。
]]>JavaScript 用了原型繼承,這表示有個叫作原型的概念。
JavaScript 用了原型繼承,這表示有個叫作原型的概念,讓我們用圖片來解說。
obj
prop1
我們可以使用點運算子取用這屬性,點運算子便會尋找 prop1,找到它的記憶體位置然後回傳。
JavaScript 所有的物件 (包含函式) 都具有原型屬性,
proto
proto
是 obj
的原型proto
是會被 obj
參考到且取用屬性和方法的物件proto
也可以有些屬性,給個名稱 prop2
當需要 prop2
屬性,可以這麼寫「obj.prop2
」,點運算子會尋找 prop2
的參考,最後找到 obj
,但是在 obj
上找不到,於是會往原型找,最後在 proto
上找到 prop2
並回傳。
這麼寫會讓人覺得 prop2
在 obj
上,但其實它在物件原型上。
相同的,原型物件也可以指向另一個原型物件。
每個物件可以有自己的原型,可能這個原型會有另一個屬性 prop3
,如果我們寫「obj.prop3
」那麼點運算子會怎麼尋找 prop3
?
obj
上沒有找到 prop3
, 所以改找原型 proto
物件proto
上也沒有 prop3
,所以又往另一個 proto
找proto
上找到 prop3
並回傳。這樣看起來像所有的 prop
屬性都在我們的主物件 obj
上,但其實是在一個稱為原型鏈 ( prototype chain )的東西上。
雖然之前也有個東西稱為範圍鏈,但其實沒什麼關連。
proto
然而這些 proto
是隱藏起來的,不需要這麼寫「obj.proto.proto.prop3
」,因此只需要「obj.prop3
」, JavaScript 會搜尋原型鏈找出 prop3
。
如果有另一個物件 obj2
,它也可以指向同樣的原型。
所以如果需要的話,物件可以共享一樣的原型記憶體位址。
就是說如果「obj2.prop2
」會與「obj.prop2
」一樣得到同樣的結果。
1 | var person = { |
首先建立兩個物件,一個是 person
物件另一個 john
物件,其中 john
沒有 getFullName
方法,我們要透過原型鏈找到 getFullName
方法。
1 | // 僅方便理解原型使用,需要使用原型時不可以使用這種方式!! |
這樣子做在實例運用上會有很大的問題,但在這邊只是為了好理解原型。
我們透過 __proto__
將 John
的原型指向 person
,並且嘗試呼叫 getFullName
方法,如同先前所述,在原型鏈上找到了 getFullName
方法。
1 | console.log(john.firstName); // John |
而當取得 firstName
時,為什麼不是「 Default
」?
因為原型鏈的關係,點運算子會優先在 john
物件內找到 firstName,一旦找到了就不會繼續往下找了。
另外同樣的例子,再度新增一個物件。
1 | var jane = { |
一樣的讓 jane
的 __proto__
指向 person
物件,指向同個記憶體位址。
1 | console.log(jane.getFullName()); |
如同預期,因為 jane
物件內有 firstName
,所以顯示出 Jane
,而找不到 lastName
所以轉往 jane
的原型鏈下找,最後找到了 lastName
的值。
老樣子,在進入新的觀念之前,都會先解釋某些特定名詞的意思,避免聽不懂講師再說什麼,本篇之後將進入 JavaScript 相當受歡迎也很困難的概念,我們要討論 JavaScript 的物件導向和原型繼承。
繼承表示「一個物件取用另一個物件的屬性或方法」,當然這在不同的程式語言間可能是不太一樣的,但對我們來說只要了解基本的概念就好。
古典繼承在 C# 以及 Java 內都有相同的概念,這可以分享物件的方法和屬性。
當然古典繼承也並非完美,舉例來說,當我們已經建立很多大型的物件,這些物件互相繼承彼此的屬性、方法,最終會得到一個很大的集合。
整體上來看這個大集合變成像是很多樹狀的物件在互動,這種情況下會很難搞清楚彼此之間的關連。
相較於古典繼承而言,原型繼承具有這些特點:
]]>當然原型繼承也不是個完美的東西,和古典繼承一樣都是有好有壞的。
討論完一級函式與 JavaScript 其他特色後,我們已經可以使用這些概念進行 JavaScript 中的函式程式設計 (Functional Programming),這很有趣也非常強大,但一開始不好掌握,需要多多練習。
JavaScript 乍聽之下好像跟 Java 有關、或是看起來有點像 C++、C#,但其實 JavaScript 與函式程式語言較相關,像是 Lisp、Scheme、ML,這些提到的語言都有一級函式的概念。
1 | var originArr = [1, 2, 3]; |
這段程式很簡單,就只是把陣列的內容乘以 2 放到另一個陣列而已,這麼寫其實不算是有錯誤,還可以處理得更好。
這段程式,可以想成把某陣列的內容透過迴圈遍歷,並傳入某函式處理後,得到新的陣列。
因此可以整理出可能需要做的事情:
1 | var originArr = [1, 2, 3]; |
透過這樣子的撰寫,可以做到同樣的事情,更棒的是可以更彈性的調整傳入的陣列輸出的結果,像是:
1 | var outputArr2 = mapForEach(originArr, function(item){ |
不必重新撰寫整個比大小的程式,只需要微調傳入的函式。
承接上面的例子,有時候可能需要傳入不只一個參數,假使我們傳入的函式需要用到更多參數,而不是上面的例子只有一個參數時該怎麼辦呢?
1 | // 進階 - 利用先前的觀念將需要兩個參數的函式變成一個參數 |
可以使用上一篇提到的 bind()
方法,將第一個參數設定固定預設值,就搞定囉,在這邊因為不會使用到 this
所以不重要。
另外同樣的做法,也可以這麼寫:
1 | var checkPastLimitEasyUse = function(limiter) { |
如果不想要每次都使用 bind() 方法,可以再用一個函式把原本的函式包覆並傳原本函式的內容,這樣就可以僅傳入一個參數使用囉。
要把程式寫成具有函式程式設計概念是需要透過很大量練習的,期許自己能透過大量的練習,早日熟練這一塊。
現在的我還不能自在的使出這一招,寫出的程式碼大多類似像這篇文章的第一個範例,需要透過事後的整理才會變成第二個範例這樣,這也是我往後進行實作時可以練習的一個重點。
]]>getDate()
撈取時間,不可用套件setTimeout()
或 setInterval()
,持續讓秒針、分針、時針能夠以台北時區移動雖然沒辦法像其他大神一樣手刻 CSS 或者使用 Canvas 繪出時鐘,不過還是最低限度的有達成本次要求,本次主要學習了 transform-origin 的用法,還 setInterval() 。
主要的概念就是計算每一小格 / 每一格的角度 , 取得當前時間後計算目前各指針的位置,並且使用 setInterval 設置每秒更新一次,為了擬真所以有額外處理讓時針偏移。
JS for 迴圈技巧
,裡頭數字不能直接寫在 HTML 上,需使用 JS 印出。因為最近都在看 JavaScript 的底層,也有好一陣子沒碰 Vue 了,怕太久沒碰生疏了,所以這次難得有這個機會,想說不然來總複習好了。
所以就使用 Vue Cli 3.0 從頭到尾跑一次,還使用了 Eslint 來折磨(?)自己,並把每一個方塊拆成元件使用,再傳入 props ,算是有好好地複習元件的概念。
雖然我的克服 JavaScript 奇怪部份的記錄還沒寫完,但是又迫不及待的把自己推入另一個火坑 - 六角的 JavaScript 地下城。
六角這次舉辦的這個地下城超棒的,不僅有提供設計稿,還有一些額外添加的規則限制,更重要的是可以看到大神們如何針對題目解題,整個就是給新手練習的最佳去處啊!
當初建立這個部落格的目的也很單純,因為我的技術目前可能並沒有好到可以跟別人分享,所以就只是很簡單的紀錄一些解題過程以及心得而已。
那麼為了不給自己無謂的寫作壓力(?),這一系列會寫的比較隨興哩。
]]>這篇文章我們要討論三個函式 (call、apply、bind),這是用來控制特殊關鍵字 this 指向的函式。
在前面的篇章提到,在執行環境中有變數環境、外部參考,還有 JavaScript 幫我們設定好的 this
變數。
我們已經看過 this
預設指向全域物件、也可以指向包含函式的物件,然而之前我們提到控制 this
的方式,是使用一個變數來儲存 this
指向的位址。
而這一篇提到的三個函式將幫助我們更有效率的控制 this
。
函式是特殊形態的物件,它具有
函式就是物件,所以函式可以有屬性和方法,而且所有函式都有 call()、apply()、bind() 方法
我們先建立一個初始的範例,如下:
1 | var person = { |
person
物件,並且在裡面建立了 getFullName
方法。this
指向 person
物件,並回傳 fullName
。logName
並指向一個匿名函式,但函式內的 this
此時是指向全域物件 windows
,因此執行 logName
函式後得到 undefined
。這時就是 bind()
出場的時候了,將程式碼改成以下:
1 | var person = { |
我宣告了 logPersonName
並且取用 logName
的 bind
方法,然後傳入想要 this
變數指向的物件,接著 bind
方法會回傳一個新的函式,它會複製 logName
函式,並且設定為一個新的函式物件。
所以當呼叫 logPersonName
函式時,因為 this
已經指向 person
物件,所以輸出就如同預期。
甚至也可以寫得更簡潔,像是這樣:
1 | var person = { |
bind
方法會產生 logName
函式的拷貝,並且將 this
指向為我們指定的物件。
1 | var person = { |
bind
方法會將函式完整的拷貝下來。
這就是 bind 的作用,創造拷貝函式,然後將 this 指向到某個物件。
接續上面的範例
1 | var person = { |
call
方法可以讓我們決定 this
要指向哪個物件、也可以傳入參數,而第一個參數就是決定 this
要指向的物件。基本上就跟 () 用法一樣,但 call
方法允許控制 this
的值。
與 bind
方法不同的地方是,call 方法並不是創造函式的拷貝並等待呼叫,而是直接執行並且改變 this 的指向。
接續上面的範例
1 | var person = { |
apply
方法與 call
方法雷同,只是控制 this
之後的傳入參數,部分略有不同。
call
方法允許傳入各種型別的值,但是apply
方法只接受陣列作為參數。
所以這兩個方法可以根據函式的情況來使用。
看過了 apply
方法與 call
方法,我們可以這麼寫:
1 | (function(lang1, lang2){ |
所有的函式都可以使用 apply、call、bind
方法。
這些方法可以如何地在實際開發上使用呢?
1 | var person = { |
我們創造了兩個物件,一個 person
物件、一個 person2
物件,但是在 person2
物件內沒有 getFullName
方法。
可以透過 call
、 apply
方法達成函式借用,即使 person2
物件內沒有 getFullName
方法,透過改變 this
的指向,也可以輸出 person2
的物件內容。
這個部分跟 bind
方法的特性有關,如果我們傳入參數給 bind,會有不太一樣的事情發生,讓我們來觀察。
1 | function multply(a, b){ |
我寫了一個會回傳 a * b
的 multply
函式,並且使用 bind
方法拷貝一份新的函式給 mulipleByTwo
,在此 this
的指向不重要,重要的是傳入的參數。
bind
方法會將傳入的參數設定為一個定值。
以例子來說 multply.bind(this, 2)
就好比這樣:
1 | function multply(a, b){ |
而在 bind
內設定了傳入的參數後,我們呼叫的函式所帶入的參數就會變成另一個,以本例來說就是 b
。
因此輸出就是 2 * 4 = 8
如果 bind 填入了所有參數呢?
1 | function multply(a, b){ |
像這樣,使用 bind
方法拷貝一份新的函式給 mulipleByThree
,並且填入 a
跟 b
參數。
當我們呼叫 mulipleByThree
帶入參數時,受到 bind
影響,所以無論帶入什麼,輸出的結果都是 12 。
於是 函式柯里化 (function curring) 的意思就是建立一個函式的拷貝,並設定不可變的預設參數。
函式柯里化主要用於數學的運算,可以寫個基本的函式,然後根據這個函式放入預設參數,用以減少需要填入的參數,這是一種 bind 的用法。
]]>整理完閉包與執行環境與一級函式後,我們確實地掌握到了這幾個名詞的基礎概念。但實際上如果曾經開發過 JavaScript ,在還不懂這些名詞的時候,或許已經無意間使用過這些技巧了,只是沒有意識到而已。像是如果使用過 setTimeout 或是 jQuery 事件,可能就使用過閉包。
像是 setTimeout 的小範例,使用了閉包與一級函式。
1 | function sayHiLater(){ |
setTimeout
接受兩個參數,一個是函式物件,一個為延遲執行時間。於是我們把一個匿名函式傳入,這是使用了 JavaScript 的一級函式觀念。
我們之前也討論過非同步過程、執行堆、 事件佇列的觀念,被包覆在 setTimeout
內的函式會先被放進事件佇列,等執行堆空了之後,才開始跑事件佇列裡的內容。
當開始運行 setTimeout
時,等到設定的時間過後便開始執行匿名函式,但 greet
不在函式裡,所以根據範圍鍊到外部環境尋找。
而雖然 sayHiLater
函式的執行環境已經不再執行堆內了,但是因為閉包,所以仍然可以取用 greet
變數。
像是 jQuery 的事件也使用到閉包與一級函式
1 | // $('button').click(function(){ |
這段程式碼將一個匿名函式當成參數傳入 jQuery 中的 click 函式,使其在進行 click 動作時會觸發這個匿名函式。
jQuery 就是這樣使用函式表示式和一級函式概念的。
當某個函式執行完畢,要給該函式執行的函式,稱為回呼。
也就是說,我呼叫函式 a
,然後給它函式 b
,當函式 a
執行完畢時,呼叫函式 b
,也就是說它回呼了 b
函式,這就是回呼函式。
1 | function tellMeWhenDone(callback){ |
以這個例子來說,我們建立了一個 tellMeWhenDone
的函式陳述句,並且可以帶入一個 callback
的參數。
接著呼叫 tellMeWhenDone
函式,並傳入一個匿名函式給它,再 tellMeWhenDone
函式執行到 callback()
時,會呼叫我們傳給它的匿名函式,因此輸出就如同我們預計的那樣。
前面提到了閉包,也講解了一些典型的範例,而閉包還有很多有用的地方。在這篇文章中,我們會談到如何利用閉包來建立函式工廠 (Function Factories),讓程式的撰寫上能更靈活,減少累贅的程式碼,這個技巧常常可在一些知名框架內看到。
我們在重載函式那一篇知道,可以有一些做法讓函式可以在不同情況下被不同的預設參數呼叫。因為我們已經了解閉包,所以我們將要使用閉包的概念讓這個範例更完善。
1 | function makeGreetFactory(languaue){ |
makeGreetFactory
函式,並可以帶入 languaue
參數。firstName
、 lastName
並且依據 languaue
有不同的打招呼方式。原本我們是將 languaue
傳入到匿名函式內,這次改成傳到外部函式,利用閉包的概念去完成。
接著宣告 greetEnglish
變數,指向 makeGreetFactory
函式並帶入 languaue
的參數值 「en」。
makeGreetFactory
被呼叫就會建立一個執行環境,以這個執行環境來說, languaue
為 「en」。同理,宣告 greetSpanish 變數,呼叫 makeGreetFactory ,此時又建立一個執行環境,以這個執行環境來說, languaue
為 「es」。
greetEnglish()
時,因為閉包的關係,即使 makeGreetFactory
函式不再執行堆裡,變數 languaue
仍可被取用。而
greetEnglish()
對應的閉包範圍languaue
值為「en」
同理greetSpanish()
對應的閉包範圍languaue
值為「es」
以這個例子來說, makeGreetFactory
函式就像一個箱子工廠,而傳入的 languaue
可以理解成不同種類的箱子。
然後我跟這個工廠說,我要一個種類為「en」的箱子,於是我得到了一個在外部寫有「en」字樣的箱子,並且裡面裝有一個會參考到這個箱子外部寫了什麼種類的函式。
]]>最後要強調的是,每當呼叫函式,函式會得到專屬於它的執行環境,然而在這個執行環境內被創造的函式會指向這個執行環境(注意創造不等於呼叫)。
接續上篇內容,這篇將用幾個經典範例用來更深入了解閉包。
1 | function buildFunctions() { |
做為人類,我們預期三個結果應該會是 0 、 1 、 2,但實際上卻是回傳 3 。
結合我們之前的觀念,當程式碼執行到 arr.push
時,匿名函式被創造,但是必須要注意的是在此時它並沒有被執行,只是把匿名函式放入陣列,接著回傳 arr
。
我們宣告變數 fs
指向 buildFunctions
函式,並且進行呼叫, for 迴圈執行完畢後 i
的值就是 3 被保存在 buildFunctions
函式的執行環境內。
而我們呼叫了匿名函式,由於函式內部沒有 i
變數,因此會轉而向外部尋找,此時雖然外部 buildFunctions
函式的執行環境不存在,但是因為閉包,所以仍然可以取得 i
的值,所以輸出才是 3 。
而其餘的呼叫也因為需要的變數都是 i
,所以指向相同的外部環境尋找變數,因此結果都是一樣的。
如同我們在 IIFE 章節得出的結果,可以使用 ES6 新增的 let 來修改程式碼:
1 | function buildFunctions() { |
let 的範圍只有 for 迴圈的 {} 內,而每次進行迴圈時都會在執行環境內不同的記憶體位址建立 i
,所以當這個匿名函式被呼叫,每次都會指向不同的記憶體位址。
1 | function buildFunctions() { |
原理很簡單,先前例子 i
的值會相同,是因為尋找到相同外部環境的同樣變數值。
那麼只要個別創造不同的執行環境保存變數 i
就解決問題了。 IIFE 可以有效地解決這個問題,因為只要函式被呼叫了,就會建立一個執行環境。
像這樣,每次進行迴圈時,都有一個 IIFE 被執行,建立了執行環境,保存當下 i
的值,然而當內部的匿名函式被呼叫時,不再需要跑到最外層去存找 i
,而是在 IIFE 那一層就可以找到相應的 i
。
這個系列會拆分成一跟二,主要介紹 JavaScript 內其中一個蠻惡名昭彰的觀念,這是一個相當抽象且不好懂的觀念,不過如果前面基礎有踏穩,其實就是之前的執行環境、範圍鏈、一級函式、執行堆等等的延伸應用在加入一點新觀念而已,課程到這邊也已經過50%了,勉勵自己繼續加油!
閉包的解釋網路上有蠻多的,但我特別喜歡卡斯伯的說法,比較適合我的金魚腦,哈!
1 | function greet(whatToSay){ |
這個寫法很有趣,我們呼叫 greet
函式,然後回傳匿名函式,因此也可以直接在後面又接一個 () ,立即呼叫內部函式。
然而內部函式雖然沒有 whatToSay
變數,但因為範圍鏈,會轉而尋找外部環境內的 whatToSay
變數。
這樣看來,都還在認知的合理範圍,但其實有點不尋常,我們修改一下:
1 | function greet(whatToSay){ |
用變數 sayHi
指向 greet
函式的位址,並且呼叫內部函式。
sayHi
函式仍然知道 whatToSay
是什麼?問題點在於
greet
函式 被呼叫,執行回傳匿名函式後,函式結束並離開執行堆。greet
函式內的變數 whatToSay
,在離開執行堆後,仍然可以被內層匿名函式取用?這是有可能的,這就是閉包的概念,讓我們用圖片了解:
當程式開始時,全域執行環境被創造。
課程截圖
到 sayHi = greet('Hi')
這行時, greet
函式被呼叫,並且 whatToSay
變數被傳入到 greet
函式的變數環境。
greet
函式執行完畢,回傳一個匿名函式, greet
函式的執行環境離開執行堆。
每個執行環境都有專屬的記憶體空間,變數與函式都被創造在內。而當執行環境沒了之後,記憶體空間會如何?
在一般情況下,JavaScript 會清除掉記憶體空間,這動作稱為垃圾回收 (Garbage Collection)。
但當閉包的執行環境結束後,記憶體空間仍然存在。
現在我們回到全域執行環境,接著呼叫 sayHi
指向的函式,然後又創造一個新的執行環境。
我們傳入了變數 name
的值,所以這會被保存在記憶體裡,但當執行到 console.log
這行時,JavaScript 發現這裡找不到 whatToSay
,因此根據範圍鍊,跑到了外部環境去尋找。
即使外層的執行環境已經離開執行堆,
sayHi
的執行環境仍然可以參考到在外部環境記憶體空間的whatToSay
變數。
執行環境可以把自身環境內會用到的外部變數整個包住。
意思是那些在執行過程中應該要被參考到的變數會整個被執行環境包住,即使執行環境已經不存在了。
然而這個包住所有可以取用變數的現象稱為閉包。
閉包是 JavaScript 本身的特色,無關於什麼時候呼叫函式,不需要擔心這個函式的外部環境是否還在執行,JavaScript 會確保無論正在執行哪個函式,都能取用到應該要取用的變數。這還是按照範圍鏈的規則在走,並沒有改變。我們下一篇將記錄一些經典的例子。
本篇要介紹的是在 JavaScript 常常會看到的問題,什麼是 IIFE ?
在前面的篇章,我們已經了解函式陳述句與函式表示式的差異。
1 | function greet(name){ |
這是標準函式陳述句,當 JavaScript 看到這個會將它放入記憶體中,等待被呼叫才開始執行內容。
1 | var greetFunc = function(name) { |
這是標準函式表示式,一開始 JavaScript 並不會將函式的部分放入記憶體,而是在執行該行程式碼時,立即地創造這個函式物件,然後可以使用指向該函式位址的變數呼叫它。
想想我們是如何呼叫一個函式的 ?
是使用「()」,我們已經達成立即創造函式物件了,如果在同一行補上()的話會怎麼樣?
於是可以這麼做:
1 | var greetFunc = function(name) { |
的確被執行了,可以讓執行結果更好一些,仿照先前函式帶入參數的方式。
1 | var greetFunc = function(name) { |
這就是立即呼叫的函式表示式 ( IIFE ),透過推導可以得知原理並不困難,就是函式表示式在創造後立刻呼叫它。
1 | var greetFunc = function(name) { |
如果改成這樣子寫,我們會得到 greetFunc
內函式的程式碼內容,加上 () 則呼叫該函式,一切都如我們預期。
但如果加入了立即呼叫呢?
1 | var greetFunc = function(name) { |
函式物件被函式表示式創造,然後被立即呼叫,接著值被回傳給 greetFunc
,所以輸出是 Hello John 。
但要注意的是,此時 greetFunc
是個字串,不是函式了,因為函式物件創造後又立刻被執行回傳字串給 greetFunc
。
1 | var greetFunc = function(name) { |
1 | var greetFunc = function(name) { |
在這個例子因為,先前提到,一開始 JavaScript 並不會將函式的部分放入記憶體,而是在執行到該行程式碼時,立即地創造這個函式物件。
所以當執行到這一行時,
var greetFunc = function(name) {
因為只是創造匿名函式物件並沒有執行,所以此時的 greetFunc 變數的值指向匿名函式的記憶體位址。
在 JavaScript 中,表示式可以這麼寫,雖然沒作用,不過是正確的表示式:
1 | 3; |
可以觀察到像是數字、字串、物件都能像這樣直接使用表示式,那函式呢?
1 | function(name) { |
問題在於語法解析器先看到 function 這個字,語法解析器認為我們應該是要使用陳述句,然而這個陳述句卻缺少了名稱。陳述句不可以是匿名的,所以語法解析器認為這有問題。
但實際上,我們想做的是不藉由其他變數,單獨的讓這個函式表示式在這。
那是不是只要讓語法解析器不要第一個看到 function 就可以了?
於是我們這麼做,最簡單的就是把這些都包進 () 裡:
1 | (function(name) { |
現在就不會報錯了,現在語法解析器知道這個包在括號內的函式不是陳述句了,而是表示式。
1 | (function(name) { |
順帶一提,結尾的()也可以寫在這邊,這兩者都是對的,只要保持一致就可以了。
我們知道 JavaScript 有全域執行環境、函式執行環境,直到 ES6 才出現塊級作用域(例如 let ),在 ES6 出來前,為了避免設定太多的全域變數,開發者往往會將變數設定在函式中,使其成為區域變數,尤其是設定在 IIFE 中,確保不會汙染到全域環境的變數。
1 | var firstName = 'Emma'; |
即使使用同樣的變數 firstName
,但 Doe
只存在於 IIFE 內,不會影響到外部環境的變數值 Emma
。
那如果反過來呢? IIFE 內想取用同樣名稱的變數值
1 | var firstName = 'Emma'; |
也只需要把全域物件 window
傳入即可。
這是一個蠻常看到的經典例子,主要是一些觀念的綜合題。
1 | for(var i = 0; i < 10 ; i++){ |
情況是這樣的,該如何修改才能正確地使執行第 i
次正確的輸出所有的 i
呢?
觀念是這樣的,因為寫在 for 迴圈內的 i
變數是使用 var
宣告的,而又沒有使用函式包覆,因此這個 i
是屬於全域執行環境下的全域變數。
1 | console.log(window.i); // 10 |
然而寫在 setTimeout
內的匿名函式,因為沒有 i
變數,所以會轉而向外部環境尋找。
setTimeout
的作用就是把函式設定執行時間後,丟到事件佇列擱著。setTimeout
的:按照設定的方式,一次跑完,至於 setTimeout
的內容是什麼不管,以本例來說就是幾乎同時設定了 10 次 setTimeout。
所以才會在輸出幾乎同時看到「執行第10次」
可以使用 ES6 新增的 let 輕鬆處理掉這個問題。因為 let 屬於區塊範圍 (Block Scope) ,變數僅存活於 {} 中,所以每次執行迴圈時取得的 i 在記憶體位址上都不同的,因此在 setTimeout
內的匿名函式參考到的 i
也都是不同的記憶體位址。
1 | for(let i = 0; i < 10 ; i++){ |
1 | for(var i = 0; i < 10 ; i++){ |
透過 IIFE 建立個別的執行環境,讓傳入的 i
值每個都可以被保存,讓 setTimeout
內的匿名函式向外尋找變數 i
時會先找到 IIFE 內的,因此就不會被外部環境的 i
影響了。
我們這篇來記錄一下 JavaScript 的空格, JavaScript 對於空格的規範其實算是相當寬鬆的,所以可以很自由的運用在排版上。
空格,創造一個看不見的字元空間在我們寫的程式碼中,像是「Enter」、「Tab」、「空白鍵」,這可以讓程式碼的可讀性更高,而且這些東西也不會被執行,我們可以利用這些空格對程式碼做一些排版,像是可以這樣:
1 | var |
當然這樣不會是最好的排版,不過只是為了說明 JavaScript 對於空格的自由度很高而已。
當語法解析器看到「//」就會忽略剩下的東西,直到下一行。然而剛才也提到空格不會被執行,所以程式輸出的最終結果就如同我們預期。
不過要注意一下「Enter」就是了,因為 No.1 的時候我們說過語法解析器可能會因為「CR」而自動幫補上分號,所以還是要小心的。
]]>本篇要提的是「重載函式」,請注意這是一個 JavaScript 沒有的功能,但是JavaScript 可以利用一級函式的特性來取代。
直接使用範例說明:
1 | function greet(firstName, lastName, languaue){ |
這是一個函式,在其他程式語言中,都有重載函式的概念,這代表可以讓同一個函式能夠有不同數量的參數,但在 JavaScript 不能這麼做,因為JavaScript 的函式就是物件,不過我們也可以利用 JavaScript 的一級函式概念來處理這些問題。
像是以本例來說,假如我們「不想要每次都傳入 languaue
」
我們已經看過可以使用 ES6 的預設參數或者使用 || 運算子,然後用邏輯運算來決定什麼情況用什麼語言。
1 | function greet(firstName, lastName, languaue = 'en'){ |
當然也還有其它的方式,像是再包一層函式處理
1 | function greet(firstName, lastName, languaue){ |
也就是說,雖然 JavaScript 沒有重載函式的概念,但是可以透過其它的方式來處理這一塊,所以不用太擔心。
]]>當我們呼叫執行函式時,其實 JavaScript 不只建立了特殊的關鍵字 this
,還建立了特殊關鍵字 arguments
,以及 ES6 後新增的其餘參數。
如同開頭所說的,當新的執行環境被創造,然後 JavaScript 會幫我們設定一些東西,像是用變數環境包住變數給範圍鏈的外部程式參考、以及特殊關鍵字 this
、 arguments
。
arguments 包含了所有傳入函式的參數。
課程截圖
1 | function greet(firstName, lastName, languaue){ |
JavaScript 與其他程式語言一個很不同的地方是,假使我們寫了一個需要帶參數的函式,呼叫函式時如果不帶任何參數也可以執行。在其他語言這樣會發生錯誤,但 JavaScript 不會,這是因為 JavaScript 具有提升的效果。
然而在 ES6 中,我們甚至可以使用預設參數的技巧,讓函式的參數有預設值,這些之前就示範過了,因此不再贅述。
1 | function greet(firstName, lastName, languaue){ |
像這樣,僅傳入對應 firstName
變數的參數值,
繼續傳入其他參數,
1 | function greet(firstName, lastName, languaue){ |
透過這樣的實驗,表示我們可以省略傳入參數也不會發生錯誤 (Error),或者是可以只傳入一部分的參數。
什麼是類陣列 (array-like),簡單來說,這是個長的像陣列的東西,但是它不支援部分陣列的方法。
1 | function greet(firstName, lastName, languaue){ |
我並沒有宣告 arguments
,但我卻可以使用,這是因為當函式執行時, arguments
就與 this
一同被創造了。
然而如同上面所述,類陣列因為不是真正的陣列,所以相較真正的陣列而言少了很多能用的方法,可觀察 __proto__
底下的得知。
也可以觀察到 arguments
包含了所有傳入的參數值,因此可以像取用一般陣列的值一樣地使用 arguments
。
1 | function greet(firstName, lastName, languaue){ |
像是我們可以利用 arguments
來判斷函式有沒有傳入參數,或者取出特定索引的值等等運用。
隨著 JavaScript 的發展,在 ES6 中,我們可以使用其餘參數來取代 arguments
,但不代表 arguments
不存在了,它仍然可以使用,只是有更好的選擇。
詳細可以參考這篇課外讀物
簡單來說如果我們有傳入函式的參數,可以使用「…」來省略,但要特別注意的是,只能在沒有其他參數下,或者「…」必須是最後一個參數才可以使用。
其餘參數比起 arguments
好用的地方在於,其餘參數是一個真正的陣列,支援所有可以用於陣列上的方法, arguments
是類陣列,處理上較為麻煩。
1 | function greet(firstName, lastName, languaue, ...other){ |
而比較典型的例子像是這個:
1 | function sum(...input) { |
由於其餘參數會回傳一個真正的陣列,所以可以使用陣列方法來計算,程式碼相對的易讀明瞭。
1 | function sum() { |
雖然 arguments
也能做到一樣的事情,但是因為不是真正的陣列,沒辦法使用 ES6 新增的一些好用的陣列方法,所以只能使用 for 迴圈一個個加總,或者使用其他方式將類陣列轉換成真正的陣列。
也因為 arguments
不能自訂一個名稱,所以也很難讓人明白到底這段程式是在做些什麼。
我們提過 JavaScript 是動態型別的程式語言,前面也介紹過動態型別的優點以及缺點,動態型別是相當強大的特性,像是用在 JavaScript 的陣列上。
JavaScript 的陣列相較於其他程式語言的陣列來說彈性了不少。受惠於動態型別的緣故,JavaScript 的陣列可以是任何東西的集合,這可以讓我們寫出一些沒接觸過 JavaScript 的人可能感到困惑的程式。
此外,陣列的索引是從0開始的、陣列也有陣列實體語法,這跟物件實體語法很相似,不多說我們直接看範例:
1 | // 陣列實體語法 |
使用陣列實體語法建立陣列,並且在陣列內放入值並使用逗號分隔,陣列是從0開始算的,代表我可以使用 console.log
抓出指定的陣列值。
在大部分的程式語言,陣列通常只能放相同型別的東西,像是一個數字陣列、一個字串陣列、一個物件陣列等等,但是 JavaScript 可以是任何東西:
1 | var arr = [ |
像這樣,我在 arr
陣列中各放了數值、布林、物件、函式 (也是物件的一種)、字串,並且輸出觀察結果。
看起來很怪異,不過在 JavaScript 中是完全合理的。
另外還可以有這樣的組合用法:
我預期呼叫陣列內的函式,並且帶入陣列內物件的 name
屬性。
1 | var arr = [ |
陣列索引是從 0 開始計算的,因此我們要先指定到函式的索引,而呼叫的方法一樣是加上 () 即可,參數的部分則是指定到陣列內物件的索引,並且使用點運算子取出屬性的值。
]]>我們了解了函式是物件的一種,有屬性及許多其它的東西。記得課程剛開始時,提過的執行環境嗎?這堂課是物件、函式,以及 this 的探討。
複習一下,當函式被呼叫時,會創造新的執行環境。當執行環境被創造,放進執行堆,這過程決定了程式怎樣執行。
每個執行環境都有自己的變數環境,也就是被創造在函式內的變數所在,並且可以參考到外部環境,能夠隨著範圍鏈一路往下找,直到全域執行環境。
而我們也知道,當函式被執行、執行環境被創造時 JavaScript 也會產生一個我們沒宣告過的特殊變數 this
。
this 會指向不同的物件,而這是根據函式是如何被呼叫的,這很重要,讓我們直接從簡單的範例了解吧。
1 | console.log(this); |
如果直接取用 this
,這個 this
會指向全域物件 window。
1 | function a (){ |
直接呼叫 a
函式,於是函式 a
內的執行環境被創造、 this
也被創造,在這個情況下, this
會指向全域物件 window。
1 | var b = function(){ |
結果也是一樣的,因為我們仍然是直接呼叫變數 b
的函式。
從上面兩個小測試可以觀察到,無論我們使用表示式、陳述句在何處創造函式,並不會影響 this
指向全域物件,因為會影響到 this
的是函式如何被呼叫。
而每一個執行環境都有自己的 this
, 在上述兩個小測試中的 this
都指向同一個記憶體位址,也就是同個全域物件,所以我們可以再透過這個延伸例子觀察:
1 | function a (){ |
在 a
函式的 this
被創造之後,我們在這個 this
上利用點運算子新增一個屬性,將這個屬性連接到全域物件,所以在呼叫 a
函式之後,我們可以透過 console.log
觀察到 newVariable
的值。
可能我們會感到奇怪,為什麼取用 newVariable
變數的時候不需要使用點運算子,因為這時候的 this
指向全域物件,而任何連接到全域物件的變數都可以直接使用。這就相當於在全域執行環境時使用 var
宣告變數一樣,像這樣:
1 | function a (){ |
我們在全域執行環境中宣告了變數 c
,並且跟上面的例子一樣直接呼叫函式 a
,並在函式 a
的程式內新增全域物件的屬性,接著觀察 window
的輸出。
可以發現如果 this
指向全域物件時,使用點運算子增加屬性到全域物件上,這時的效果會同於直接在全域執行環境上使用 var
宣告變數。
透過上面的範例,我們已經了解函式表示式、陳述句,因此這次我們在一個物件內建立一個函式。記得我們先前提的,物件是許多名稱 / 值配對而成的組合,當值是純值時稱為「屬性」;當值為函式時稱為「方法」。像這樣:
1 | var c = { |
現在情況就有點不一樣了,並不是直接呼叫函式,而是呼叫被創造在物件時體內的函式,因此要取用物件內的成員,必須使用點運算子,並且加上()呼叫該函式,也就是 c
物件的 log
方法。
因為呼叫的方式改變了,在這個範例中 this
會指向有 log
方法的 c
物件,因此我們可以利用這個特性,在方法內修改 c
物件的 name
屬性,像這樣:
1 | var c = { |
可以看到 name
屬性被修改了!
讓我們將範例混合起來觀察, this
是否仍然如我們所想的那樣:
1 | var c = { |
結果令人驚訝嗎?其實並不,這是可以解釋的。
在 log
方法內,我們雖然又新增了一個 setName
的函式,並且是直接的呼叫它,但是會影響 this
的是函式呼叫的方式,並非實際上程式碼的實體位置,因此雖然方法內的 this
是指向 c
物件本身,但在 setName
函式內的 this
仍然是指向全域物件 window
。
所以我們可以在全域物件 window
中找到剛剛新增的屬性
其實很容易,我們說過物件是傳參考的,我們只需要創造一個變數,並把想保存的 this
利用等號運算子設定給該變數就可以了,像這樣:
1 | var c = { |
這樣就不需要考慮每個時候的 this
究竟是指向誰,只需要知道要保存下來的 this
是指向誰就可以了。
在這個例子中,我希望保存 this
指向 c
物件的記憶體位址,因此用了變數 self
配合等號運算子,令其與 this
指向同樣的 c
物件的記憶體位址。這樣即使之後 this
變動,也已經 self
無關,我們仍然可以使用這個變數修改 c
物件。
關於 this 部分還有很多例子可以細細觀察,這部分可以參考 卡斯伯的鐵人賽文章 - JavaScript 的 this 到底是誰?
當然 console.log(this)
隨時查一下 this
指向哪裡也是可以的,配合著這些觀念,會讓我們寫 code 更順利哦~
本篇要記錄 JavaScript 相當重要的觀念「傳值與傳參考」,了解這個觀念是相當重要的,兩者都是討論關於變數的東西,讓我們開始吧。
還記得純值 (Primitives value) 是什麼東西嗎?
我們把其中一種純值設定到變數 a
中,所以現在這個變數 a
知道了這個純值的記憶體位址。
接著我們創造一個新的變數 b
,並且令 b = a
,變數 b
會指向一個新的記憶體位址,並拷貝那個純值,放到新的記憶體位址。
這種方式稱為傳值 ( By Value )
在 JavaScript 中,所有的物件 (包含函式物件),全部都是傳參考的。
當設定一個變數 a
並且賦予值為物件類型,變數 a
仍然會得到物件的記憶體位址。
但當令 b = a
,變數 b
此時不會得到一個新的記憶體位址,而是會指向變數 a
的記憶體位址,並不會創造新的拷貝物件。就好像別名一般,此時的 a
與 b
這兩個名稱都指向同一記憶體位址。簡單來說,此時的 a
與 b
的值是同樣的,因為它們指向相同的記憶體位址。
1 | // by value |
透過上面的敘述,可以了解,為什麼 a
與 b
都是 3 了。
因為 3 是數值型別,所以當 b
被設定為 a
時,等號運算子看到 3 是純值,所以創造一個新的記憶體位址給 b
,接著拷貝 a
的值填入 b
的位址。
所以 a
是 3 、 b
也是 3 ,但它們是對方的拷貝,在兩個不同的記憶體位址。
也就是說當
a
被更動時,b
不會受到影響。
1
2
3
4
5
6
7 // by value
var a = 3;
var b;
b = a;
console.log(a, b); // 3 3
a = 2;
console.log(a, b); // 2 3
1 | var c = { |
我們給變數 c
設定了一個物件,同樣的, c
知道了物件的記憶體位址。當執行到 d = c
時,等號運算子看到物件不會創造新的記憶體位址給 d
,而是把 d
指向和 c
相同的記憶體位址。
所以結果是相同的 ,但它們不是對方的拷貝, c
和 d
只是指向相同的記憶體位址。
也就是說當
c
被更動時,d
也會受到影響。
1
2
3
4
5
6
7
8
9
10 // by reference (all objects (including functions))
var c = {
greeting: 'Hi'
};
var d = c;
console.log(c);
console.log(d);
c.greeting = 'Hello';
console.log(c);
console.log(d);
當物件用於函式的參數上時,物件也是透過傳參考的方式被傳入,觀察一個例子:
1 | var c = { |
我們傳入變數 d
到函式中,此時 obj
會指向 d
的記憶體位址,但接續前面的例子, d
已經指向 c
的記憶體位置,而 c
被設定了一個物件。
所以當使用 obj.greeting
改變了值,表示會更新這個物件所指向的記憶體位址內的值,因此輸出 c
與 d
的值,可以發現都被改變了。
有件事情要特別注意,使用等號運算子賦予新值(記憶體還不存在的值)時,會設定一個新的記憶體位址,接續上面的例子:
1 | var c = { |
我使用等號運算子設定變數 c
為一個新的值,然後等號運算子會設定一個新的記憶體空間給 c
,並且放進那個值。自此, d
和 c
就不再指向同一個記憶體位址。
所以這是一個特殊的例子,這並不是傳參考。
等號運算子看到 { greeting: 'Howdy' }
還不存在於記憶體,這是一個創造物件的物件實體語法,所以並不是一個已經存在的物件。因此等號運算子必須建立另一個新的記憶體空間給物件,然後指向 c
。
與例子上半部 d = c
不同的地方是 c
已經存在了
因此等號運算子知道 c
已經在記憶體中,不需要另外創造記憶體空間,而且 c
是個物件,只要把 d
指向同一個位址就好。
我們延伸例外情況一,使之變得更為複雜:
1 | var c = { |
這個答案是我們想的那樣嗎?
答案不是{ greeting: "Hola" }
,為什麼?
我們使用物件實體語法創造一個物件並且令變數 c
指向自身記憶體位址。
接著我們知道當物件用於函式的參數上時是傳參考的。因此此時的 obj
與 c
指向同一個物件的記憶體位址。
但是,當程式碼執行到
1 | obj = { |
同例外情況一看到的,等號運算子看到 { greeting: 'Hola' }
還不存在於記憶體,這是一個創造物件的物件實體語法,所以並不是一個已經存在的物件。因此等號運算子必須建立另一個新的記憶體空間給物件,然後指向 obj
。
因此這個時候 obj
已經與 c
指向不同的記憶體位址了,自然 c
指向的物件並不會被改變。
在寫這篇的時候,發現到有些文章好像對於傳值、傳參考的細節描述都有一些些不同的地方,像是這篇文章 - 深入探討 JavaScript 中的參數傳遞:call by value 還是 reference?寫得很仔細,而且其實技術的名詞定義紛爭也是不少,像是這篇。
最後擷取一段胡立大大文章的句子作為例外情況的總結:
]]>JavaScript 傳 object 進函式的時候,可以更改原本 object 的值,但重新賦值並不會影響到外部的 object
現在我們知道,在 JavaScript 內函數就是物件,接著要運用這個觀念,來做一些實際運用!但在開始之前,需要先了解函式陳述句 (Function Statements)、什麼是函式表示式 (Function Expressions) 之間的用法差異。
表示式為程式碼的單位,會回傳一個值 (A unit of code that results in a value)
白話來說,函式表示式或者任何形式的表示式最終會創造一個值,然而這個值不一定要儲存在某個變數,且這個值可以是任何東西。
舉個例子,我們在 .js 檔內宣告變數 a
並且打開開發者工具,輸入以下:
這是一個簡單的表示式,我們把 3 透過等號運算子賦予給變數 a
,並且執行它,得到回傳的結果。
我們說過,值不一定要儲存在某個變數,因此這樣也是表示式:
這個表示式回傳了 3 ,我們並沒有使用等號運算子將這個值放入變數。
然而,表示式也可以是這個形式
當我們提到陳述句,陳述句代表「會做某件事」。
1
2
3
4 var a;
if (a === 3){
}
當 if (a === 3)
就做某件事情。
在 if 陳述句的括號內,必須放進表示式產生一個值,這樣這個陳述句才能運作。
另外陳述句本身不會回傳任何值。
像是我不能這麼做,這是無效的,因為沒有任何值會被回傳給變數 b 。
1 | // 錯誤示範 |
簡單來說,陳述句會做其他事;表示式則回傳值
接著我們來看看這兩者之間的差異,直接看例子。
1 | function greet() { |
這樣就是一個簡單的函式陳述句,當創造執行環境時, greet
函式被放進記憶體中,但因為 greet
是函式陳述句,所以不會回傳任何值,直到函式被呼叫執行。
雖然函式陳述句不會回傳任何值,但它會有提升 (hoisting) 現象,所以可以在任何地方取用它,像這樣:
1 | greet(); |
1 | var anonymousGreet = function greet() { |
宣告一個 anonymousGreet
變數並且使用等號運算子,然後在右側使用函式陳述句。
記得我們說的「函式就是物件」,所以可以當作「我們建立了一個物件,並設定它等於這個變數」,也就是這個變數在記憶體中指向的位址。
另外,我們已經有一個已經知道函式物件位址的變數 anonymousGreet
,所以等號右邊的陳述句可以改寫成這樣,稱為匿名函式。
1 | var anonymousGreet = function() { |
我們需要指向該物件,並且告訴它執行程式,像是這樣
1 | anonymousGreet(); |
因為變數已經知道了函式物件的記憶體位址,只需要加上()來呼叫函式就可以執行了。
另外還有一個值得注意的問題,函式表示式的提升 (hoisting) 現象,如果我們將程式改成這個樣子:
1 | anonymousGreets(); |
結果會變成這樣,為什麼呢?
還記得當執行環境被創造,創造執行階段會把函式陳述句以及變數都放入記憶體,變數被賦予初始值 undefined
,然後逐行執行程式碼。
於是程式的第一行是「anonymousGreets();
」,但此時仍未賦予變數值,變數的值仍然是 undefined
。自然的,錯誤便會告訴我們 undefined
不是函式,它沒辦法被使用 () 呼叫執行。
1 | var anonymousGreets = function() { |
直到上述這行程式碼,anonymousGreets
變數的值才被賦予函式物件。
代表函式表示式不受到提升 (hoisting) 影響。
記得我們說的函式是物件,函式表示式可以馬上創造函式物件,因此我們可延伸出以下寫法:
1 | function log(a){ |
我們立即創造了一個函式物件,在裡面寫了一些程式碼。然後把這個函式物件當成參數傳入 log
函式內。
不過這樣只是印出函式物件的內容而已,但透過這樣的觀察得知「一級函式可以很快地被創造、使用,且變數也可以設值成為一級函式」
我們結合上述這些並做些修改:
1 | function log(a){ |
因為我們傳入 log
函式的參數為函式物件,所以變數 a
參照到了這個函式物件。同樣地,要呼叫執行函式僅需要加上 () 即可。
本例來看,我使用函式表示式,接著把這個函式傳入當作另一個函式的參數,這樣另一個函式就可以使用這個函式表示式,這就是我們提到的一級函式的觀念「可以將函式傳入別處」。
可以把函式給另一個函式,就像使用變數一樣,這樣的做法也稱為函式程式語言 ( functional programming )。
]]>在這節課中,作者說明了一個很重要的觀念,也就是在 JavaScript 中,函式也是物件的一種 (functions are object) 而且也會提到什麼是一級函式(first class functions)
一級函式從字面上看來或許很複雜,但其實不會。一級函式指的是,可以對別的型別,像是物件、字串、數值、布林做的事情,全部都可以對函式做。
一級函式可以讓我們用完全不同的方法解決問題,所以當我們說「函式就是物件」,那麼函式物件是什麼東西呢?
就像 JavaScript 內的其他物件般被存放在記憶體,這是個特殊形態的物件,函式物件具有所有物件的特色,此外還有一些其他屬性:
程式屬性特別的是,這是可以被呼叫 (invocable) 的,代表可以執行這個函式的程式。
1 | function sayHi(){ |
像這樣,我新增了一個函式的屬性,就像前面介紹物件的文章一樣,運用點運算子為物件添加屬性,接著將輸出看結果。
如果直接這樣寫,會得到函式的文字內容,在這個範例中這樣子沒什麼用。所以必須使用點運算子取得函式物件的屬性,就像之前在一般物件做的那樣:
1 | console.log(sayHi.languaue); |
所以當我建立了這個函式,實際上它看起來像這樣:
sayHi
的函式物件sayHi
的函式物件具有程式屬性,因為我寫了程式碼在裡面sayHi
後面加上括號 () ,呼叫 sayHi
函式並執行這樣的例子說明了,函式只是一種特殊的物件,它除了具有一般物件有的特性外,還有自身的特殊屬性。
]]>我們需要花點時間討論物件實體,解釋常見錯誤,以及一些大家使用 JSON 會遇到的誤解。
它是被 JavaScript 的物件實體語法啟發的,因為這樣所以看起來很像物件實體語法 (Object Litetal Syntax),也被稱為 JavaScript Object Notation。
1 | var objectLitetal = { |
這個例子在 JavaScript 是有效的,我們可以得到 objectLitetal 物件的結果,這很容易,因為我們之前講過了。
1 | { |
在 JavaScript 中現在我們大多用 JSON 格式傳送資料,上述的例子只是個資料字,看起來很像物件實體語法,不過還是有一些小差異,例如:
這麼寫仍然是個有效的物件實體語法,我們可以做個測試
1 | var objectLitetal = { |
於是我們得知:
技術上來說,JSON 是物件實體語法的子集合,意思就是只要在 JSON 中視有效的,那麼在物件實體語法中就是有效的。但反過來就不一樣了,不是所有的物件實體語法在 JSON 格式中都是有效的。
JSON 的規則比較嚴格且 JSON 並不是 JavaScript 的一部分。但是 JSON 很受歡迎,因為它可以很簡單的被 JavaScript 解析。
JavaScript 有一些內建的函式可以轉換這兩者,意思就是可以讓任何物件變成 JSON 字串,也可以讓任何 JSON 字串變成物件。
承接上面的例子,我們可以這麼做:
1 | var objectLitetal = { |
這是 JavaScript 內建的功能「.stringify
」,這會把物件轉換為 JSON 字串。
1 | var jsonValue = JSON.parse('{ "firstName": "Willy", "isProgrammer": true }'); |
本篇會記錄這些只是因為常常搞不清楚物件實體語法和 JSON 字串的差別,這是兩個不同的東西,但 JavaScript 提共了轉換這兩者的功能。
]]>來談談另一個框架小叮嚀,更進一步的討論應用所學到框架中,我們可以在知名的框架中常看見這些東西,在前面討的那些主題,像是點運算子、物件實體語法後,本篇要紀錄的是「偽裝命名空間」。
在現代的程式語言,命名空間指的是變數和函式的容器,命名空間只是一個包裝物、一個容器,通常這是用來維持變數和函式名稱分開。
但是這裡有個問題,JavaScript 並沒有命名空間
雖然 JavaScript 沒有命名空間,但是因為物件的本質,我們不需要命名空間這個功能,我們可以透過物件來「假裝」:
1 | var greet = 'Hello!'; |
非常明顯我們會得到西班牙文的 Hola。
但我們可以想像這兩個變數其實是被創造在兩支不同的 JavaScript 檔案中,並且在 HTML 中引入,一個是英文的打招呼另一個是西班牙文的打招呼。
但是現在它們互相覆蓋了,偽裝命名空間可以幫助解決這個問題,因為命名空間可以有個容器裝著英文的問候語而另個容器裝著西班牙的問候語。
所以我們可以這麼做:
1 | var english = { |
可以看到雖然 english
物件和 spanish
物件內都有 greet
變數,但它們不會互相衝突、不會互相覆寫,於是我們可以印出來觀察看看。
然後這個 english
變數基本上就成為一個容器,用來確保裝在容器內的東西不會和其他 JavaScript 檔案中因為相同的全域命名空間衝突。
除了這樣用之外,還有更進階的用法。
接續剛才的例子,像是我想要在 english
內有個打招呼的命名空間在裡面,在裏面有不同的問候語。
1 | var english = { |
得到了錯誤,記得運算子的相依性嗎?在此例由於都是點運算子,且相依性為左相依性。
於 english
內尋找 greetings
屬性時找不到,所以回傳 undefined
,而 undefined
根本不是物件,所以不會在裡面找到 greet 。
1 | english.greetings = {}; |
或者我們也可以使用物件實體語法來做,這會簡單很多:
1 | var english = { |
我們可以用以上任何一種方式來做偽裝命名空間,透過這樣的方式,可以把變數、函式、方法或其他物件放進容器物件裡,這就是 JavaScript 偽裝命名空間。
]]>上一篇我們介紹了如何使用「.」運算子取出物件屬性的值,記得一開始的那行程式碼嗎?課程上提到有「更好的方式」建立物件,本篇就是要來介紹那個「更好的方式」。
上一篇有一句程式碼是這樣的,這是宣告 new object 來建立物件,但這不是最好的方式。
1 | var person = new Object(); |
在 JavaScript 中,對於相同的一件事情可以有多種不同的做法來達成,像是我們想要建立一個物件,可以使用 new object
來建立,但針對建立物件這件事情而言,這個作法不是最好的,之後課程會提到 new object
真正的意涵。
建立物件有個捷徑,它叫做物件實體語法,以大括號決定範圍,這就像我們使用 new object
來建立物件一樣。
特別注意的是 {} 並不是運算子,這是當 JavaScript 在解析語法時,看到大括號而不是作為 if 條件式或迴圈時,就會認定是要創造一個物件。
1 | var person = {}; |
還記得我們在上一篇要新增物件屬性時,必須這樣子寫:
1 | // person.firstName = 'Tony'; |
但如果使用物件實體語法,我們可以在大括號裡同時建立屬性與方法:
1 | var person = { firstName: 'Tony', lastName: 'Alicea' }; |
整理一下排版,會得到這樣的結果
1 | var person = { |
是不是覺得似曾相識呢~沒錯,這個方式就如同使用 new object
來建立物件,會得到一樣的結果,但是透過物件實體語法建立的物件會比較清楚易懂。
1 | var person = { |
當我們了解物件實體語法是什麼東西後,便可以相當迅速地建立物件,也可以從中衍生出一些用法。
舉個例子:
1 | var Tony = { |
這個結果如我們預料,我們呼叫 sayHi
函式並將物件當成參數傳入,最後印出結果,但是我們可以同時呼叫函式和建立物件
1 | sayHi({ firstName: 'Mary', lastName: 'Alicea' }); |
或者也可以做換行整理
1 | sayHi({ |
JavaScript 可以使用物件實體語法在任何地方建立物件,也可以把這個當作一般變數使用。
接續上面的範例,我們可以這樣做
1 | Tony.address2 = { |
因為我們寫的程式碼並不會真的直接被輸出,而是會透過 JavaScript 變成電腦能懂的東西,不論物件實體語法或是使用「.」運算子建立物件都是一樣的,對於 JavaScript 底層來說,這件事情就是「建立物件、物件屬性和方法到記憶體中並且建立參考」,所以我們用的語法對它們而言沒差,因為都是做同樣的事情,重要的是我們要用什麼語法。
這就是物件實體語法,用大括號定義範圍、用冒號區隔屬性名稱和值。
]]>終於進展到一個新的階段:物件與函式,在 JavaScript 中,物件與函式兩者是非常相關的,它們在很多情況下幾乎是一樣的,讓我們從本篇之後來談談物件與函式,但在這之前得先了解本篇標題「物件與點」
之前說過物件是一群「名稱 / 值」的組合,然而這些值也可以是另一個「名稱 / 值」的組合。
一個物件是一群「名稱 / 值」的組合,但這個「值」可以是什麼?
物件可以有屬性和方法,屬性可以是「純值」,也可以是另一個「物件」;另外物件內也可以有函式。當函式在物件內時,我們稱之為「方法 (method)」
在記憶體中,核心物件會有一個記憶體的位址,透過這個記憶體的位址,能夠參考到其他位於記憶體中的屬性、方法的位址並找到這些屬性、方法的所在。
1 | // 課程提到有更多更好的方法建立物件 |
在 JavaScript 中取得物件的屬性和方法有兩種方式,這是其中一種利用中括號「[]」,一個稱為「計算取用成員」的運算子。
在中括號內放進值的名稱,這是要存在記憶體中的東西。這時它還不存在,所以我們要令它等於 Tony
。
這樣就會正式在記憶體中創造,並且有個名稱叫做 firstname
的東西。然後 person
物件就能夠參考到 firstname
在記憶體內的位址,並且知道 firstname
的值是一個字串,這就是物件的「屬性」。
這個方式好用的一點是,可以利用變數修改物件的屬性名稱,像是:
1 | var person = new Object(); |
但除非有需求,不然還是使用「.」運算子會比較方便。
另外我們也可以印出物件看看目前長怎樣:
這個方式用起來會比較清楚且常見,使用方法也很簡單:
1 | console.log(person.firstName); |
創立物件的新屬性名稱也可以運用「.」運算子完成:
1 | person.dog = 'John'; |
簡單來說這個點就和中括號一樣,是「成員取用」運算子,這個成員指的是物件的成員。所以這些運算子會幫我們找到物件的成員,像是方法和屬性。
前面提到物件內也可以有另一個物件,我們用「.」運算子來做:
1 | //完整程式碼 |
我們可以再次印出 person
物件觀察,便可發現新增的物件被包覆在 person
物件內了。
另外程式碼中的「person.address.street= ‘111 Main St.’;
」因為用到了兩次運算子,所以我們查一下運算子的優先性與相依性得知,「.」運算子為左相依性,因此會從左邊開始運算到右邊。
白話來說就是會先到 person
物件內找到屬性物件 address
, 然後再從 address
內找到 street
屬性並且設定字串值。
物件在記憶體中是「名稱 / 值」的組合,可以包含另一個物件,也可以包含純值像是字串、布林、數值,也可以包含函式( 在此稱為「方法」),但本篇還不會討論到「方法」,因為還有一些概念要先了解。
]]>這篇文章主要紀錄關於我們學過的東西如何應用在受歡迎的框架中,後續的課程中會不斷地做這件事。雖然框架和資源庫有不同的意義,但我們在這裡會當成是同一個東西。
我們可以結合一些之前學到的東西像是預設值、執行環境、全域環境等等,了解一些常出現在知名框架中的程式碼。
首先,來進行一些準備工作:
如果都做好了,會像這樣
然後我們再 all.js 內執行「console.log(libraryName);
」
結果會相當令人驚訝,因為結果是「Lib 2」
這是因為在在 HTML 內引入 .jS 檔時,並不會幫這三隻檔案建立獨立的執行環境,執行環境仍然只有一個,這麼做只是把程式碼分開,更直接地說,它們其實長得像這個樣子 (按照 HTML 內的引入順序):
1 | var libraryName = 'Lib 1'; |
配合我們之前學到的觀念, libraryName
自然是會輸出 Lib 2 。
現在我們知道了這三隻檔案最終會合併成一個檔案,那麼如何使這三隻檔案不互相衝突是相當重要的。
如果我們不希望全域變數 libraryName
的值被後來的值影響,可以這麼寫:
1 | // lib2.js 內 |
如果 window.libraryName
有值,那麼就不做其他事情,反之如果沒有值,就賦予 Lib 2 字串。
這樣子的做法就是在檢查全域命名空間 (Global Namespace) 或全域物件,看是否已經有東西用了那個名稱了,透過這個方法就不會造成衝突或是覆蓋。
]]>回顧一下運算子的強制型轉,我們可以從另一個角度來利用這些特性。
用例子來觀察:
1 | function sayHello(name) { |
透過之前的觀念,我們可以預期如果呼叫函式時,帶 / 不帶參數會出現什麼結果。
但是雖然程式沒有出錯,但是不帶參數時卻跑出 undefined
還是很奇怪的,如果想要避免這樣的情形該怎麼做呢?
以本例來說,可以給 name
一個預設值
以目前來說有兩個做法,先講之前的做法:
1 | function sayHello(name) { |
這就是一個運算子的強制型轉的應用,我們現在的印象停留在「運算子只是會回傳值的函式」,所以這個「||
」會回傳什麼,我們觀察看看!
「||
」不單只是回傳 true
或 false
,在「undefined || 'hello'
」中回傳字串 hello,這是因為「||」會回傳可以被強制型轉為「true」的值,讓我們繼續觀察:
而「||
」還有一個特性就是,當兩個值都可以被強制型轉為「true
」的時候,僅會回傳第一個。
||
」特性||
」會回傳可以被強制型轉為「true
」的值true
」的時候,僅會回傳第一個值這就是「||」運算子的特殊行為
1 | function sayHello(name) { |
以這個例子來說,當我呼叫這個函式不帶入參數時,因為 name
此時是 undefined
,進行「||」運算後回傳預設字串。
但當我帶入參數呼叫函式時, name
此時並非 undefined
所以進行「||
」運算後回傳第一個可以被強制型轉為「true
」的值。
最後因為「=
」優先性低於「||
」,所以「||
」回傳值後,「=
」才將值回傳給變數 name
。
透過利用運算子的強制型轉特性,比起使用 if 陳述式設定預設值,我們可以讓程式碼變得更精簡易讀。
1 | function sayHello(name) { |
如果我們帶入數值 0,得到的結果會有問題,因為數值 0 會被轉換為 false
,此時的「||」會回傳預設字串,然而這是特例就是了~
在大多數的時候,這樣子寫還是很好用的。
1 | function sayHello(name = '我希望的預設值') { |
用這個作法就不用擔心數值 0 的特例了,是不是很方便呢!
]]>我們了解 JavaScript 是動態型別以及具有強制型轉的特性,也知道強制型轉的缺點,所以我們也必須了解其優點,關於存在 (Exeistence) 與布林 Boolean)
同於 Number()
可以進行強制轉型為數值型別,Boolean()
則是轉為布林型別。
1 | console.log(Boolean(undefined)); // false |
透過例子我們可以理解, JavaScript 會把 undefined
、 null
、空字串強制型轉為布林後的值為 false
。
那麼該如何利用這個特性?
1 | var a; |
如果我們把變數 a
放到 if 陳述句內,則 a
會被轉換成布林值。還記得變數在執行環境的創造階段會發生什麼事嗎?變數會被賦予預設值 undefined
,所以在強制型轉後布林值為 false
,所以這個例子輸出的結果是什麼都沒有。
我們回過來看一開始的例子,當 a
的值是 undefined
、 null
、空字串時,這個 if 陳述句會不成立,因為 a
是不存在的、是 false
的。
如果我們給 a
其他值
1 | var a; |
這時會因為 a
不是空字串,所以是存在的,輸出結果就如同我們預期。
換言之,我們可以利用強制型轉來檢查變數有沒有值
然而,有個危險的地方要特別注意:
1 | console.log(Boolean(0)); // false |
數字 0 轉換成布林值也是 false
,我們再將這個狀況帶入剛剛的例子。
1 | var a; |
如果最後變數的值為數值 0 ,那麼這個 if 陳述句就有問題,並不如我們預期了,因為 0 並不是不存在,但 a
卻被強制轉型為 false
,因此我們需要稍作修改,可以運用「運算子的優先性」。
1 | var a; |
可以透過優先性與相依性表格得知,「===」的優先性高於「||」,所以「===」會優先執行,以這個例子來說會得到 true
的結果。
整理一下,目前情況是這樣:
1 | if (false || true) { |
讀作「如果 false 或 true 」的話,回傳 true
,在這個函式中其中一個值是 true
或都是 true
的話,就會回傳 true
。
最終成為這樣
1 | if (true) { |
印出「show something」
不過如果我們可以確保變數不是數值 0 的話,只需要寫 if (a) 看看存不存在就可以了。如果我們能夠確實地掌握這些觀念,就不會常常因為 if 陳述式跑出非預期的結果而感到困惑了。
]]>來談談 JavaScript 的比較運算子,之前我們紀錄的所有東西,單獨來看可能不知道可以被實際運用在哪裡,直到把運算子的「優先性」、「相依性」、「強制型轉」這三種東西兜再一起,這就是本篇文章要記錄的東西。
當我們把「優先性」、「相依性」、「強制型轉」這三種東西兜再一起時,就會發生一些 JavaScript 看似奇怪,但實際上非常合理的事情,但是不用擔心,因為這些東西我們都學過了。
直接看例子:
1 | console.log(1 < 2 < 3); // true |
合理嗎?
常識判斷「 1 小於 2 小於 3 」,正確!
那如果是這樣呢?
1 | console.log(3 < 2 < 1); // true |
看到這,是不是會懷疑是不是有BUG?
為什麼「3 小於 2 小於 1」是正確的?
記得「運算子的優先性」與「運算子的相依性」嗎?
讓我們查一下表格
在這個例子中,有兩個「<」代表優先性是一樣的,而「<」的相依性為「左相依」,代表最左邊的會先執行。
於是,實際過程中是這樣的:
1 | //實際上會先執行以下判斷 |
3 並不小於 2 因此回傳了 false
,接著原先的例子就會變成這樣
1 | console.log(false < 1); // true |
接著我們可能會感到更奇怪了,布林 false 如何與數值 1 比較?
這並不是預期中的型別,但仍然可以進行比較,這是因為 JavaScript 中的「強制型轉」。
「強制型轉」把布林
false
轉換成了什麼數值?
可以運用以下方法觀察
1 | Number(false); // 0 |
看到了嗎,JavaScript 把 false
強制轉型成為數值 0 ,讓比較繼續進行
1 | console.log(0 < 1); // true |
因此最後輸出的結果才會是「true
」
懶人包:因為函式運算子執行的順序以及值的強制型轉影響
當然,也可以回頭想想文章一開始提到的例子,雖然結果與我們所想的相同,但實際上底層的運作跟我們想的並不一樣哦~
強制型轉雖然很便利,但上面的例子可以看出也有缺點,不是每個強制轉型後的情況都是可以預期的,我們先用剛剛的觀察方法觀察一些強制型轉後的變化。
1 | Number('3'); // 3 |
表示不是數字 ,NaN 代表有個東西想轉換成數值型別,但它不是數值,所以無法轉換。以本例來說 undefined 不能轉成數值。
但是,null 經過強制型轉後得到的答案是 0 ? 有符合我們的預期嗎?
我們可以透過觀察得知,並不是每次轉型結果都很明顯,這會導致相當多難以預期的 BUG。
強制轉型雖然很便利,但同時也很危險。
既然這樣不使用不就好了? 或者先檢查兩個東西是否相等?
的確可以這樣做,讓我們看看 JavaScript 運算子優先性表格。
好的,我們找到了程式語言相當常見的雙等號「==」,代表「檢查兩個東西是否相等」,讓我們直接來點例子:
1 | console.log(3 == 3); // true |
在我們知道強制轉型的概念後,大部分的例子都可以了解為什麼,但是我們剛剛不是才觀察過 null
可以被強制轉型成數值 0 嗎,怎麼在這邊是 false
呢?
有很多特殊情況並不如我們所想的那樣,來個延伸例子
1 | console.log(null < 1); // true |
雖然 null
會轉型為 0 ,但在某種情況下,像是在「比較」的時候並不會轉型為 0,困惑嗎?
沒錯,這造成了很多疑惑和問題。
然而我們也透過上述其他例子得知,使用「==」進行比較時,會進行強制型轉。也因為強制型轉的關係,會使得結果可能不如我們預期。這其實也被視為這個語言的缺點。
是不是開始覺得混亂了。 我也是,幸好這問題有解!
用三個等號比較兩個東西,就不會進行強制型轉。
使用三等號驗證剛剛使用雙等號比較的例子
1 | console.log(3 === 3); // true |
如果兩個值不是同個型別,就會回傳 false
。此時的 JavaScript 不會進行強制型轉。
使用三等號來比較值的話,可以避免ㄧ些奇怪的潛在錯誤。在絕大多數 99.9% 的情形,我們都應該使用三個等號來比較相等性,除非你是「故意」要使用雙等號。
然而還有很多關於雙等號、三等號的比較,可以到這裡來查看。
]]>我們提到 JavaScript 宣告變數時不需要指定型別,因為 JavaScript 是動態型別語言,這篇文章要記錄的觀念是「強制型轉 (coercion)」
強制型轉的意思就如同字面上解釋,強制轉換一個值的型別。
舉例來說:有個數值型別,然後要轉換成字串型別,這在 JavaScript 是很容易發生的,因為動態型別的關係。
例一:
1 | var a = 1 + 2; |
例二:
1 | var a = '1' + '2'; |
這兩個例子都用到了先前提到的運算子「+」,這是一個函式,處理後回傳了一個值。像是例一,我們傳入了兩個數字給加號運算子,它便進行加法的運算 ; 例二則是傳入字串 1 以及 字串 2 ,回傳了字串 12,兩個字串被連接了。
1 | var a = '1' + 2; |
前面提到, JavaScript 是動態型別的程式語言。注意我們並沒有輸入任何程式碼進行轉型,一般而言,字串和數字是無法直接相加的。但我們第一個傳入的參數卻被 JavaScript 強制型轉成字串,而不是得到一個 Error 。
JavaScript 在處理這一段的時候,有個規則,當「數值」與「字串」做相加時,數值的部分會被強制型轉成為字串,以本例來說輸出結果為字串 12 。
另外如果是這樣寫:
1 | var a = '' + 2; |
雖然字串內沒有寫任何東西,出來的結果也是預期的 2 ,但實際上這邊的 2 是字串 2 ,如果繼續與其他數值計算可能發生預期之外的結果,因此了解強制型轉的概念是相當重要的。
]]>上一篇我們大概了解了什麼是運算子,這節要來討論運算子的相依性與優先性這兩個非常重要的名詞。它們看起來很複雜,但其實並不。
運算子優先性表示「哪個運算子優先被運算」,是 JavaScript 用來判斷當同一行程式碼有不只一個運算子時,決定優先權的方式。具有高優先性的運算子會先計算依序執行到低優先性的運算子。
運算子相依性表示「運算子被計算的順序」,有分成從左到右的「左相依性」或是右到左的「右相依性」。
這代表當多個運算子有相同優先性時,會按照怎麼樣的順序被計算
1
2 var a = 3 + 4 * 5;
console.log(a); // 23
我們來解釋一下為什麼答案會是 23 。
以數學的四則運算來看,先「乘除」後「加減」有「括號先算」,JavaScript 同樣也有規則,這個例子因為有三個運算子,所以需要呼叫三個函式,但是 JavaScript 並不會同時計算它們。也就是說,這三個函式會按照優先權決定誰先被呼叫。
MDN 的運算子優先性網頁
透過查詢,我們可以得知以下:
第一欄是運算子的優先性,數字越高代表有越高的優先權。
第三欄是運算子的相依性,決定當優先權相同時該如何運算的順序。
從這表格得知,「*」優先性為 14,「+」優先性為 13 ,因此「*」函式會優先被 JavaScript 呼叫,接著才是「+」。
1 | var a = 3 + 4 * 5; |
計算的順序為 4 * 5 計算後回傳 20 , 然後 3 + 20 回傳 23 再將值設定給 a
。
如果完全不按照優先性,計算上會產生相當大的變化,因此優先性很重要。
1 | var a = 2, b = 3, c = 4 |
優先性我們已經知道了,「=」的優先性為 3,這個例子來說,運算子的優先性都是一樣的,那麼接著看相依性,「=」為「右相依」 (right-to-left),是用來傳入值的運算子。
也就是它會先呼叫這邊的「=」運算子
1 | b = c; |
這會讓 b
的值等於 c
,而 c
的值為 4 ,因此「=」函式回傳 4 給 b
。
同理,接著執行另一個「=」函式運算
1 | a = b; |
受到上一個「=」函式影響此時的 b
為 4 ,因此這次的「=」函式將回傳 4 給 a
。
還記得文中有提到數學有「括號先算」的例子嗎?
]]>在 JavaScript 中也是有的,可以查到「括號」的優先性是 20 ,它的作用是區分出一個群組,將被包覆住的東西永遠優先被計算。
1
2 var a = (3 + 4) * 5;
console.log(a); // 35
我們已經探討過型別,現在我們深入了解另一個概念「運算子」,能幫助我們除錯和了解其它可能會因為動態型別而產生的問題。
運算子是一個特殊的函式 (Function),不過這和我們自己寫的函式不太一樣。一般而言,運算子都需要兩個參數來回傳一個結果,來個例子:
1 | var a = 3 + 4; |
這答案很簡單,但 JavaScript 是怎麼處理的呢?
語法解析器看到「+」號後就會把前後兩個數字加起來,而「+」號就是運算子,「+」是加法運算子,它是一個特殊的函式。就很類似這樣:
1 | function +(a, b) { |
如果我們要呼叫這個函式,我們可能會寫
1 | +(3, 4); |
但是這麼做很麻煩,我們必須寫出函式的名稱,而不能直接寫個加號。
JavaScript 跟很多程式語言一樣,可以使用「中綴表示法」表是即可,意思是將函式名稱寫在兩個參數中間,讀起來簡單易懂。
運算子的本質就是,一個具有兩個參數的函式,而這個函式會回傳一個值。以這個例子來說,就是將這兩個參數相加,然後回傳 7 這個數值。
1 | +3 4; //前綴表示法 |
1 | var a = 4 > 3; // true |
大於是一個函式,「>」符號是ㄧ個運算子,這和上述的例子有點不同,它需要兩個參數,然後回傳布林值。以本例來說,左邊的 4 大於 右邊的 3 ,所以會回傳布林值 true 。
當我們輸入這些運算子像是「加、減、大於、小於」符號或其他運算子時,它們其實是特殊的函式,這些參數都被傳入這些函式中,處理後回傳一個值。在這些函式中已經寫好了一些預設的程式碼, JavaScript 讓我們利用這些運算子呼叫函式。
]]>現在只要記著,運算子都是函式。
上一篇提到 JavaScript 是動態型別,會根據我們設定的值自動決定該變數的型別為何, JavaScript 有六種純值(Primitive Types,或稱為基本型別),讓我們繼續看下去。
純值代表的是一種資料的型別,表示一個值。換句話說這不是一個物件 (名稱 / 值的組合),而純值也稱為基本型別。
在前面的幾篇文章內我們或多或少都有看過它,這是 JavaScript 給所有變數的初始值,這代表這個變數會一直是 undefined 直到給定變數一個值,這也是我們為什麼不應該用 undefined
來設定變數的值,這會很容易讓人混淆「究竟是變數是還未給值或是設定變數為 undefined
」
null 表示不存在,如果我們要用來表示一個變數的值為「不存在或是空值」,使用 null 是比使用 undefined 好的。
這個有寫過程式的應該都很熟悉,代表這個變數的值是「真」或是「假」的其中一種可能,在 JavaScript 內直接用 true 或 false 表示。
在 JavaScript 只有一種數字型態, 所以可以把它想成自動判斷整數或浮點數,不像其他的程式語言,可能有整數型態或者其他特定數值的型態。
由一連串的字符所組成,在 JavaScript 中用單引號或雙引號包覆住,都可以用來表示字串。
Symbol 是 ES6 新增的一個基本型別, Symbol 是一種特殊的、不可變、且獨一無二,可以作為對象屬性的標識符使用。
關於 Symbol 的更多敘述,可參考 這裡
基本的用法大概是這樣的:
1 | //Symbol 字串用法 |
進階用法 & 注意事項:
Symbol 作為屬性名時,該屬性不會出現在 for…in 、 for…of 迴圈迭代中,也不會在 Object.keys()
、 Object.getOwnPropertyNames()
等等之類的方法中被獲取,只有唯一使用 Object.getOwnPropertySymbols()
此方法才能獲取擁有 Symbol 值的屬性名。
另外 Symbol 不可以使用 “new Symbol()
“
1 | let a = Symbol('name1'); |
Symbol.for()
: 可以重新使用同一個 Symbol 值,若定義時輸入參數一樣,則 Symbol 值會相等。Symbol.keyFor()
:可以取得在使用 Symbol.for()
定義時所輸入的參數值可以使用這兩個函式方法重複利用資源
1 | let a = Symbol.for('name'); |
本篇要記錄的是 JavaScript 的一個小觀念「型別」, JavaScript 很特殊,不同於其他程式語言,特別是在變數的資料與型別的部分,JavaScript 處理它們的方式不太一樣。
這是一個描述 JavaScript 處理型別方式的名詞,意思就是不需要告訴 JavaScript 變數是何種型別資料、不必再程式碼內寫出來,JavaScript 會在運行程式時知道。
也就是說當我們執行程式時,一個變數可以在不同時候有不同型別,因為型別都是程式執行時才知道的,因為 JavaScript 是動態型別的關係,我們不需要關鍵字來宣告變數的型別,像是:
1 | var isNew = true; //no errors |
JavaScript 會根據我們設定的值,決定要給這個變數什麼型別。
像是其他的程式語言 (C#、Java),就是使用靜態型別的方式處理,這種方式必須一開始就告訴編輯器,我們的變數是什麼型別,但如果你將其他型別的值放入就會導致錯誤,如:
1 | bool isNew = 'hello'; //error |
初次學習 JavaScript 的時候會覺得這個東西很方便,但是實際上這樣也是很危險的,變成我們必須很明確地知道這個變數目前的型別是什麼,否則最後運算出來可能不會是我們要的結果。這種情況反而會比較喜歡靜態型別,直接把型別決定好,這樣可以避免一些曖昧不明的情況發生,也比較方便除錯。
]]>我們在 Day 6 的時候有談到 JavaScript 是 單執行緒且同步的,是如何同步執行這些程式碼,那我們也有一些疑問仍然有待釐清,像是「非同步回呼 ( asynchronus callbacks)」是什麼意思?
非同步表示在「同個時間點內不只一個」,可能一段程式在執行時,又開始執行另一段程式碼,然後又執行別的程式碼,這些程式碼在 JavaScript 是同時在執行的。
但我們之前有說過 JavaScript 是「同步」的,並不會「非同步」的執行,它是怎麼樣處理「非同步」事件呢?
首先我們必須知道,瀏覽器內不只有 JavaScript 還有一些其它像是Rendering 、HTTP Request …等等,在 JavaScript 外執行別的程式。JavaScript 可以向 Rendering 溝通來改變網頁的樣子,或者向HTTP Request 請求資料。但這些可能都是非同步執行,表示 JavaScript 的請求在瀏覽器內是「非同步」的,裡面只有 JavaScript 本身是「同步」的。
克服 JS 奇怪部分 截圖
所以當我們「非同步」向外請求,或是某人點擊了一個按鈕執行了某個函式時,這些被「非同步」處理的事情,會怎麼樣?
還記得我們之前提的「執行堆」嗎?在 JavaScript 內的等待列則稱為「事件佇列 (Event Queue)」這裡面都是事件、事件通知,這些可能要發生的東西。所以當瀏覽器在 JavaScript 外的某處有個需要被通知的事件時,會將之放進佇列裡,這個事件可能需要有個函式來回應它,我們可以監聽這個事件,並且處理它。
要特別注意的是,當執行堆是空的 JavaScript 才會注意事件佇列,在執行堆還沒空之前,是不會處理事件佇列的。
在處理事件佇列時,JavaScript 會看是否有函式被這個 CLICK 事件觸發,處理後知道有一個函式需要執行,因此又創造執行環境給那個函式,並加入執行堆,接著處理完畢後,又繼續到下一個佇列的事件,如此循環。
也就是說,這不是真正的「非同步」,而是瀏覽器「非同步」的把東西放到事件佇列,但原本 JavaScript 的程式仍然一行行執行,當執行後、執行堆空了、執行環境清除了,才開始處理事件佇列內的事件,周而復始。
來點範例:
1 | function wait(){ |
我們寫了兩個函式,一個用來模擬需要花長時間動作的函式,結束後會印出函式結束的字樣,另一個用來監聽出現在事件佇列的瀏覽器的點擊事件。我們透過這個範例來觀察,如果我重新整理網頁後,當 wait
函式仍執行時,點擊畫面 / 不點擊畫面,這些 console.log
的輸出會是如何,是不是如我們上面所說的一樣。
透過以上觀察,我們可以得知,當執行堆是空的 JavaScript 才會開始處理事件佇列內的事件,這也表示執行長時間的函式,可以干擾事件
這就是 JavaScript 如何用「同步」的方式處理在瀏覽器別處「非同步」的事件,當事件佇列都結束了之後, JavaScript 會繼續看事件佇列的迴圈,這稱為「持續檢查 (Continuous Check)」,當事件再度出現在事件佇列,就會執行。
也就是說非同步回呼在 JavaScript 是可能的,但是非同步的部分,是發生在JavaScript 之外。JavaScript透過事件佇列迴圈,當執行堆沒有東西時,就開始處理事件佇列內的事件,但是是「同步」的處理。
]]>我們在前面幾篇文章中已經討論「執行環境」、「變數環境」、「詞彙環境」,然而這些東西最終定義了「範圍 (Scope)」。
範圍是變數可以被取用的區域,如果我們呼叫了相同的函式兩次,它們會各有一個屬於自己的執行環境,因此如果函式內有個同樣的變數被宣告兩次,該變數在記憶體中的位置是不一樣的。
上面那段話用例子來解釋的話是這樣:
1 | function a(){ |
在 ES6 (ECMAScript 6) 中,有個新的宣告變數的方法 let
,這個可以像 var
一樣使用,但並不是取代 var
, var
還是存在。
但 let
讓 JavaScript 使用了一種稱為「區塊範圍 (Block Scoping)」的東西,直接看一個片段程式碼:
1 | if (a > b) { |
我們可以宣告一個變數大致就像使用 var
一樣,變數一樣會被創造在執行階段、被放入記憶體中但不會被設值為 undefined
,較為不同的是,直到執行階段,這一行程式被執行、真的宣告變數後,才能存取用 let
宣告的值。
我們改寫一下上面的例子,如果試著在 let c = true
之前取用 變數 c
:
1 | var a = 10; |
會很直接的跳出 c is not defined
區塊通常是被定義為在「大括號 {}」中,像是在 if
、 for
裡面,當變數被宣告在區塊內,變數就只能在裡面被取用。舉個區塊範圍及 let
的例子:
1 | for(var i = 0; i < 10 ; i++){ |
像是這個例子,每次進入迴圈執行到 let a = 0
時,變數 a
在記憶體中都是不同的存在,這就是區塊範圍。
以下引述自 胡立 大大寫的文章:我知道你懂 hoisting,可是你了解到多深?
老實說,我本來以為沒有,但實際上 let
也是有「提升 (hosting)」的,再來個例子:
1 | var a = 10 |
如果 let
沒有「提升」的話,這個範例的輸出應該會是「10」,因為 console.log(a)
會因為 test
函式內找不到變數 a
而跑到外部環境尋找。
但是!這範例的輸出是
ReferenceError: a is not defined
意思就是,它的確提升了,只是提升後的行為跟 var
比較不一樣,所以乍看之下你會以為它沒有提升。
與 var
的差別在於提升之後, var
宣告的變數會被初始化為 undefined
,而 let
的宣告不會被初始化為 undefined
,而且如果你在「賦值之前」就存取它,就會拋出錯誤。
在「提升之後」以及「賦值之前」這段「期間」,如果你存取它就會拋出錯誤,而這段期間就稱做是「TDZ」,它是一個為了解釋 let
的 「提升」行為所提出的一個名詞。
後續就到原文處當課後補充吧。
其實我在寫這篇文章的時候發現了一個衝突點,這讓我決定先上論壇尋求一下協助再來做修改,衝突點就是「 let
宣告的變數,會不會在創造階段內被放入記憶體中,且初始化為 undefined
」,影片之中的講師是說會,胡立的文章是說不會,不過這兩者時空背景不太一樣,所以要求證一下。
這是求證結果 ,因為是課程問答區可能沒有買的人不能看…。
let
/ const
宣告的變數,確實也有提升的作用,只是因為 TDZ 所以並不像使用 var
宣告會得到 undefined
,而是會直接拋出 ReferenceError
。
let
/ const
在創造階段時,也會把變數寫進記憶體,但是在此不會賦予預設值 undefined
,而是在之後的程式執行階段,如果以 let
/ const
宣告的變數沒有賦予值時,才會給予 undefined
,這時候 let
/ const
的初始化才算完成,此時 let
/ const
宣告的變數才能被正常使用。
這一篇要記錄的觀念是常常會讓人感到困惑,光看名詞就會讓人不知道在講什麼的「範圍鏈 (Scope Chain)」,但實際上如果照著課程走,看到最後會發現,啊原來就只是這樣!所以不要被名詞嚇到了哩。
現在我們已經了解「執行堆」、「執行環境」、「變數環境」,讓我們直接看一個範例,會相當眼熟:
1 | function b(){ |
No.2 的時候有提到「外部環境」,還記得「執行堆」的觀念嗎?當我們進入到 b
函式時,會製造一個執行環境,放到執行堆的上面,並把所有已經宣告的變數都放入它的「變數環境」,會在 b
函式的變數環境內尋找 myVar
這個變數,但 b
函式內 myVar
沒被宣告,此時會參考到 b
函式「外部環境」的 myVar
,因此印出來的結果會是 1 。
還記得一開始提到的「詞彙環境」嗎,就是代表「程式碼被寫出來的實際位置」,這意味著 JavaScript 引擎會如何處理這些程式在記憶體中的位置、以及與其他程式的連結。
所以回到例子,函式 b
以詞彙上來說,它在全域環境之上,代表 b
函式不在 a
函式內,它與最後一行的 myVar = 1
同層級。
事實上執行環境的實際位置不重要,函式 b
可以在 函式 a
上,或者互換位置,重要的是「執行的順序會決定函式如何被呼叫、執行環境如何被創造」。
JavaScript 相當注重詞彙環境,當你需要某個執行環境內的某個變數時,如果沒辦法找到此變數,它會到外部環境尋找變數,直到執行堆最下方尋找。這是整個範圍鏈到外部環境的過程。
所以如果有許多函式互相呼叫,搜尋範圍鏈會不斷的向下移動,直到全域階層。如果這些函式是互相在內部被定義,也會逐層的在那些外部環境參照尋找,直到找到或者沒找到,這一整個過程就稱為「範圍鏈」
「範圍 (Scope)」代表能夠取用這個變數的地方;「鏈 (Chain)」是外部環境參照的連結;「詞彙」這是實際上程式碼被寫出來的物理位置
1 | function a(){ |
我們改變了 b
函式的詞彙環境,它在 a
函式內了。
這代表現在我們不能在全域的地方呼叫 b
函式,因為全域執行環境會尋找 b
函式,但 b
函式根本不再這個變數環境內, b
函式在 a
函式內。
我們又可以得知一點,當 JavaScript 創造全域執行環境時,它發現 a
函式,但不會看 a
函式內有什麼東西,會直接到 a
函式結束的地方繼續看下去。所以如果直接這樣寫:
1 | b(); |
會得到 b is not defined
的錯誤。
讓我們再來推論一次:
a
函式,函式 a
的執行、變數環境被創造,此時 myVar
的值為 2b
函式時,函式 b
的執行、變數環境被創造b
內的程式碼 console.log(myVar)
,當 JavaScript 往範圍鏈下面找時,它在函式 b
內找不到 myVar
,便轉向外部環境的函式 a
找,因此結果會是 myVar = 2
1 | function a(){ |
延續上面的情境,當 JavaScript 往範圍鏈下面找時,它在函式 b
內找不到 myVar
,便轉向外部環境的函式 a
找,這個時候函式 a
也找不到 myVar
了,但是 a
函式的外部環境參照是全域執行環境,因此找到 myVar = 1
。
至此,我總算是明白了 JavaScript 在我們沒看到的地方偷偷摸摸的為我們做了什麼事情,如果明白了這些,也對於工作上的除錯有相當大的幫助,因為我們了解了JavaScript 底層做了些什麼,所以當出現一個非預期的值,我們也能快速的除錯。
]]>本篇是討論另一個有關 JavaScript 底層運作的名詞,「變數環境」?這看起來並沒有這麼想像中的複雜,看下去就明白了。
變數環境只是在描述創造變數的位置以及與記憶體中和其他變數的關係。簡單來說,當提到「變數環境」時,我們只要想著「變數在哪裡」這樣就可以了,來點例子:
1 | function b(){ |
套用前面幾篇學到的觀念,我們可以得知,當這些函式被執行時會發生什麼事,讓我們在溫習一次並加入本次的新名詞:
myVar
放進記憶體中,對全域執行環境來說,它的變數環境是全域物件 (瀏覽器的 window),所以當程式運行到 var myVar = 1
時,在記憶體中的變數會得到 1 的值。a
函式,一個新的執行環境被創造給 a
函式,也會創造一個新的變數環境,每個執行環境都有自己的變數環境,然後在執行階段時,變數 myVar
的值會變成 2 ,接著 a
函式會再呼叫 b
函式。b
函式開始執行,一個新的執行環境、變數環境又被創造,當執行到 var myVar
時,因為沒有設定值,所以這裡的值會是 undefined
。這些和一個叫做「scope」的東西有關,我們之後將會介紹它。
以上面的例子來說,每個變數都被定義在自己的執行環境,每當我們呼叫函式,就會得到這函式自己的執行環境,雖然 myVar
變數被宣告三次,但事實上,這三個 myVar
是完全不相干、彼此沒有關聯的,讓我們來證明它:
1 | function b(){ |
綜合前面的觀念,推斷後,我們可以得知答案會是
1 | 1 |
換句話說,在這個例子內,三個 myVar
都待在自己的執行環境內,呼叫 a
函式並不會影響到 var myVar = 1
的值,讓我們再下方多補入一行
1 | console.log(myVar) |
則得到的結果會是
1 | 1 |
完全符合前面幾篇文章討論過的觀念。
突然有種逐漸明朗的感覺,事實上我自己在寫這些紀錄的時候,都會有種「這我好像在前面幾篇文章就有看過」的感覺,這應該是這門課程的特色,它是循序漸進的,會在同一件事情上「慢慢地」加入一些要素、觀念,最後當我們每個名詞都弄懂之後,自然也就明白為什麼會這樣了。下一篇要記錄有關「Scope」,感覺篇幅應該不小哦~加油吧!
]]>在前面我們已經知道了全域執行環境是如何被創造、執行的,在本篇我們要記錄的是「函式呼叫 ( Funtion Invocation)」、「執行堆 (Execution Stack)」究竟是什麼東西~這對之後的進階觀念相當重要!
表示執行或者呼叫一個函式,在 JavaScript 我們用括號來表示這件事,像是這樣:
1 | a(); |
這麼做我就呼叫了 a
函式,儘管我根本沒宣告 a
函式,但我希望 JavaScript 執行這個。
這個就得從 「當我們在 JavaScript 呼叫函式時發生了什麼事」開始說起,一個簡單的範例:
1 | function b(){ |
狀況是這樣的:我們有兩個函式,
a
函式會呼叫b
函式。我們呼叫a
函式,這時候來看會發生什麼事情。
this
,創造全域物件 (在瀏覽器會是 window 物件),然後會將這些變數、函式放進記憶體中。接著程式碼會開始逐行被執行,直到碰到 a()
。a()
時,一個新的執行環境被創造,被放進執行堆中,就像積木一樣被堆疊起來,在最上方的就是正在執行的東西。所以每一次在 JavaScript 呼叫函式,就會創造一個新的執行環境並放進執行堆中。然而一個新的執行環境會有自己的記憶體空間存放變數與函式,一樣也會歷經創造階段,逐行執行函式中的程式碼。a
函式內的程式碼呼叫了 b
函式,此時會停止執行程式再次創造另一個新的執行環境,然後執行 b
函式,儘管 b
函式內沒有任何程式碼。b
函式結束後,它會離開執行堆的最上方,然後是函式 a
,但在此函式 a
內沒有其他的程式碼了,也離開了執行堆,最後回到最下面的全域執行環境。程式碼中的實際排列順序並不重要,在函式中剩下的程式碼順序也不會影響執行先後,像是把程式碼改成這樣:
1
2
3
4
5
6
7
8
9 function a(){
b();
var c;
}
function b(){
var d;
}
a();
var d;
如前面所提,這些函式、變數在創造階段就已經在記憶體中了,因此當程式碼逐行被編譯時,也很類似上個範例:
a
函式被呼叫,進入執行堆,變成目前執行的程式,因為 var d
在 a
函式的下方且 JavaScript 是同步的,一次執行一行,所以暫時沒被執行。a
函式的內容, b
函式被呼叫,進入執行堆,變成目前執行的程式,注意這時 a
函式並沒有離開執行堆。b
函式的內容,當執行完之後 b
函式就離開執行堆,此時 a
函式又變成執行堆的最上面了,因此會執行還沒執行的那一行 var c
。a
函式 執行完畢離開執行堆,回到全域執行環境,還記得一開始的 var d 嗎 ? 終於輪到它了!透過一個比較簡單與進階的例子,我們了解 JavaScript 函式呼叫的流程,即使是自己呼叫自己,仍然會遵守這個流程。此外,無論在執行堆最上面的東西是什麼,那就是目前正在執行的程式。
]]>這篇紀錄 JavaScript 另一個觀念「單執行緒」、「同步執行」
每個程式都有一堆指令,單執行緒表示「一次只執行一個指令」。
這跟單執行緒很類似,同步執行的意思是,「一次執行一行程式碼,而且是按照順序的。」
單執行緒 & 同步執行意味著在 JavaScript 中一次只會發生一件事
當然我們可能聽過 JavaScript 中有什麼非同步請求 (asynchronous requests),還有非常非常多的疑問,不過在此就只要先記住這些就行了。
]]>還記得我們說到執行環境有兩個階段,第一個階段是「創造」,這會設定變數與函式到記憶體中。這篇文章主要是介紹第二個階段「程式執行 (Code Execution)」
在創造階段時,我們已經設定好所有的東西了,而這個階段就是關於「我們寫好的程式碼」將被逐行的編譯、轉換成電腦懂的東西。
1 | function b(){ |
這樣子會印出什麼呢 ?
如我們前面學到的觀念,因為現在是程式執行階段,函式 b
被呼叫印出 called b
,接著印出在變數 a
於創造階段設定的 undefined
,執行到 var a = 'hello'
時將記憶體中 a
的值設定為 hello
字串,最後執行 console.log(a);
印出變數 a
的值。
這一篇文章比較短,不過透過這一篇的範例我們可以知道, JavaScript 中程式碼確實是逐行被編譯的。
至此,執行環境的兩個階段已經講解完了,自己算是對 JavaScript 的前世今生也有一點點眉目了,但 JavaScript 仍然有很多奇怪的地方需要克服,勉勵自己繼續加油。
]]>上一篇的最後,我們提到了「undefined」,於是我們這一篇要探討什麼是JavaScript中的「undefined」,在正式進入本篇之前,先用影片的一段話幫助自己回顧一下前一篇的內容。
在執行階段的第一階段「創造階段」時,會產生一個「全域物件」到全域執行環境裡,還會產生一個特殊變數「 this 」,如果有「外部環境」的話也會產生相應連結,另外還有個特殊現象稱為「提升 (hoisting)」,變數和函式會預先被儲存在記憶體內,而變數會被設定一個初始值「undefined」
我們這次專注觀察 undefined
,移除掉其他不相干程式碼
1 | console.log(a); |
記得這畫面嗎?
對,我們會得到一個 undefined
那如果是這樣呢?
1 | var a; |
還是會得到一個 undefined
如果不透過宣告就使用變數 a
呢?
1 | console.log(a); |
則會產生錯誤(Error), a is not defined
「undefined」的意思用上述例子來說,我們透過 var
宣告讓 JavaScript 知道有 a
變數的存在,因此會在創造階段就在記憶體內預留空間給變數 a
,並且賦予一個 JavaScript 內建的特殊值 undefined
,用來表示這個變數 a
還沒有被「設定」過值,以下再來個例子證明。
1 | var a; |
特別注意程式碼內的
undefined
沒有使用單引號包住,undefined
並不是字串,而是一個特殊的值。
這樣的結果會是
那如果改成這樣
1 | var a = 'hello'; |
則結果會是
因為 a
被「設定」了值,所以不再是 JavaScript 給變數的預設值 undefined
。
「not defined」的意思,簡單來說就是 JavaScript 根本不認得這是什麼,因為這個變數沒有被宣告過,故創造階段時並沒有在記憶體內預留空間給這個變數,所以才會是「not defined」。
在例子中,透過 var
宣告讓 JavaScript 於創造階段時,為變數在記憶體內預留空間,並且給予特殊的初始值 undefined
,因此 undefined
並不是「不存在」,這是會佔據記憶體空間的。
之後當執行到 var a = 'hello';
時,才正式將字串 hello
給變數 a
;反之,如果沒透過 var
宣告, JavaScript 根本不認得這是什麼,所以自然會跑出錯誤(Error)的提示囉。
這個小節其實相對的好懂,影片的最後講師也提到了設定變數時,盡量不要把初始值也寫成undefined
,像這樣:
1 | var a = undefined; |
因為這樣會讓除錯變得困難,會很難分辨這個 undefined
是我們設定的,還是 JavaScript 給予的預設值。
透過前面的篇章我們得知, JavaScript 於執行環境時,會幫我們建立出一個「全域物件」與「this」,那麼除此之外呢?今天就是探討更多有關 JavaScript 在創造「執行環境」時做的「創造」與「提升 (hoisting)」
1 | var a = 'hello'; |
這樣寫很明顯答案會是:
1 | b(); |
我們可能會很直覺地說,這樣會產生錯誤,因為程式碼是逐行執行的,而 b c函式與 a 變數還沒被宣告。
但在 JavaScript 不是這樣的
b
函式正確地被執行了,但是變數 a
的值變成了 undefined
。即使函式是之後才宣告的,但仍然正確執行;變數雖然值被改變了,但仍然可以使用,這到底是為什麼呢?
我們有很多疑問,但在此先移除變數
a
看看會發生什麼事情
1
2
3
4
5 b();
console.log(a);
function b(){
console.log('called b');
}
這次我們就很直接地獲得了一個 Error , a is not defined
這些現象在 JavaScript 稱為 「提升 ( hoisting )」
「提升」的解釋很容易被誤會,例如「提升」是 JavaScript 的變數與函式被實際的從程式碼內被『提升』到最上方了,像是這樣:
1 | b(); |
自動地變成這樣
1 | var a = 'hello'; |
無論在哪宣告都沒有差別。
這樣子似乎也沒什麼錯,但實際上不完全正確,因為 a 也沒有被設值為 hello,這麼說比較像是這樣子
1
2
3
4
5
6
7 var a;
function b(){
console.log('called b');
}
b();
console.log(a);
a = 'hello';
但這樣也不對,不然比較正確的說法是什麼呢?
我們要回到最開始,到執行環境剛開始的時候。執行環境剛開始的時候被分成兩階段創造,第一階段是「創造 ( creation )」 階段,在這個階段中我們透過前面篇章知道有「全域物件」、「this」、「外部環境」再創造階段裡。
因此當「語法解析器」開始轉換程式碼時,它會知道我們已經在哪創造變數、函式,這是在創造階段就被設定變數以及函式在記憶體裡,這個步驟叫做「提升 (hoisting)」
這個步驟並不是真的把我們的程式碼移動到最上方,而是在程式碼被逐行解讀之前, JavaScript 已經為變數、函式在記憶體中建立一個空間了。
正是如此,當程式被逐行執行時,可以找到在記憶體中的變數、函式。
透過上面解釋,我們知道函式已經完整地在記憶體內了,代表函式內的程式碼已經被執行了。然而下一個執行階段
1 | var a = 'hello'; |
JavaScript 為 變數 a 騰出記憶體空間時並不知道 a
是什麼值,會先放上undefined
的特殊值,直到該行程式碼真正被執行時才知道。
假如設定變數但不給值會發生什麼事,像是這樣?
1 | var a; |
所有的 JavaScript 的變數一開始都會被設定為 undefined
依賴「提升」並不是好的作法,這麼做可能會遇到問題,而且在可讀性上也會大大的降低,所以雖然技術上來講可以說得通,但並不建議這麼做,可以避免一些未知的 BUG 。
透過影片講解,我們了解「提升 (hoisting)」為何物,即使函式是最後才被宣告,我們還是可以先行呼叫,因為我們寫的程式碼並不會直接被執行,而是會透過 JavaScript 的轉換,並於第一個創造階段把變數、函式設定在記憶體內,因此我們可以有限的取用它們,直到實際出現在詞彙環境 (lexcial environment)之前。
課後補充一:卡斯伯的網誌
觀念跟文章說的是一樣的,只是函式寫法不同,都會被「提升 (hoisting)」影響。文章中的函式寫法是「函式陳述式」,所以在函式前方直接調用也可以運行。另外還有一種是函式寫法是「函式表達式」,會先把函式指定給一個變數,這時候如圖中範例調用,會產生 undefined
,因為「所有的 JavaScript 的變數一開始都會被設定為 undefined」,因為是 undefined
自然也無法運行函式的內容了。
課後補充二:胡立大大寫的 techbridge 技術報
內容相當詳盡,其中有一句很濃縮,又很多人不明白的:
]]>let 與 const 也有 hoisting 但沒有初始化為 undefined,而且在賦值之前試圖取值會發生錯誤。
今天課程談到了關於「全域執行環境」與「全域物件」,那麼這兩個東西又是什麼呢?首先我們需要了解到:
執行環境就像一個別人寫好的程式把正在執行的程式碼包裹在裡面內驗證、執行。
前面提到的基礎執行環境,也可以稱為全域執行環境 (Global Execution Context),「全域」指可在區域裡、JavaScript 檔案裡、詞彙環境裡被取用。
全域執行環境創造了兩件事情:「全域物件 (Global Execution Object)」、「特殊的變數 this」
還記得物件的解釋嗎,就只是 名稱 / 值 的組合,再帶入「全域」的概念
接下來我們可以開始寫一點程式碼測試看看,像是這樣:
這個時候其實已經做了這些事情:
但是 JavaScript 確實已經運行,執行環境已經被創造出來了,另外還有「全域物件」、「 this」,我們可以進行驗證,執行環境內有什麼?
我們什麼都沒寫,為什麼會有這個 window 物件
因為 JavaScript 已經將執行環境建立,而執行環境可以決定現在這個 this 的值是什麼
那如果我們直接輸入 window 會怎麼樣? 我們會得到一樣的結果。
執行 JavaScript 時永遠會有一個全域物件
在瀏覽器的話就是 window ,如果再伺服器上執行 node.js 那結果會是得到 Global ,會是個不同的全域物件。另外,不同分頁的全域物件 window,雖然名稱一樣,但彼此是沒有關聯的。
每個分頁有自己的執行環境、全域物件。
整理一下目前的思緒,得出如下圖結論
原文是這麼寫的「Not Inside a Function」,在 JavaScript 中表示不再函式(Function)內,更直白一點「程式碼或者變數不再函式裡就是全域的」,讓我們直接來驗證。
1 | var a = 'Hello world'; |
至此,我們有了一個變數、函式,與之前不同的是,現在我們有程式碼了。而且變數 a 不再 b 函式裡,現在的情況是「全部的程式碼都不再函式裡」,讓我們觀察看看它們是不是「全域」的。
如紅框處,我們建立的變數 a
、函式 b
,如果不是在函式內建立,那麼這些東西就會與全域物件做連結,也就可以產生以下寫法,兩者是一樣的。
至此,本篇大致上到這裡結束,再利用圖片加深一下印象
還有一些事情沒有提到,像是「外部環境 (Outer Environment)」,意思就是說,當程式碼在函式內執行,這代表程式碼在函式內;當在程式碼在全域時,代表不是在函式內,此時就沒有「外部環境 」,因為這已經是最外層了,此時會是 null
,這樣講好像有聽沒懂,再來個例子:
1 | function b(){ |
猜猜這樣寫函式 b
會印出什麼?
答案是 1,為什麼?
原因很簡單,在 JavaScript 中,如果要呼叫一個在該「執行環境」中沒有的變數時,它會往它的「外部環境 」去找。
也就是說:
myVar = 1
),因此全域執行環境有了 (myVar = 1
)a
,但這裡的 myVar
不是全域變數,而是區域變數 (myVar = 2
)b
,但對函式 b而言並沒有變數 myVar
,此時函式 b
會從「外部環境 」尋找變數 myVar
,也就是一開始宣告的全域變數 (myVar = 1
),而不會是函式 a的(myVar = 2
)感覺自己又對 JavaScript 更了解了一點,不過瞭解這些專有名詞是有點想睡覺…,但是瞭解這些名詞其實有助於後續課程的學習,你必須知道這些名詞是什麼意思,才能理解講師再說什麼。另外外部環境我覺得是個蠻重要的東西,了解這個有助於避免掉一些鬼打牆的情況。
]]>終於開始這個系列的第一篇,所以先來記錄一些比較基礎的名詞解釋,往後的課程中也會多次的提及這些詞彙,因此透過這篇記錄下來,避免自己金魚腦忘掉。
語法解析器可能會是一個「直譯器」或是「編譯器」,例如在 JavaScript 中我們寫的程式沒辦法直接告訴電腦該做什麼,那該怎麼辦?
需要透過一個「中間人」(在此指編譯器,也就是別人寫的程式)
概念像是這樣 ↓
透過語法解析器,我們寫的程式碼會逐字地被解讀,如果程式碼正確,那麼語法解析器就會將其轉化為電腦看得懂的硬體指令,最後呈現出我們預想的結果。
像是如果程式碼內寫了
return;
,當語法解析器看到「r」時,它預期會再看到一個「e」,語法解析器會逐字地進行。
如果語法解析器看到一些非預期的東西就會出現錯誤,但如果是正確的,就會繼續下去,並在遇到分號時結束。
語法解析器甚至可以在執行前改變我們的程式碼,像是幫忙補上分號,這也是為什麼 JavaScript 中,有時候我們忘記寫分號,程式仍然可以執行。
不過幫忙補上分號有時候並不是一件好事,有時這會讓程式難以除錯,所以我們應該避免語法解析器自動補上分號。
因為有時語法解析器做出不如我們預期的行為,像是我們輸入 return
按下「Enter」 鍵,會出現一個「Carriage Return」,這是個看不見的字元,但它確實存在,這時語法解析器會受「Carriage Return」影響,進而把有「Carriage Return」的地方替換成分號。也就是說,任何語法解析器預期會有分號的地方都會自動地補上。
1 | function getPerson(){ |
因為 return
後面有「Carriage Return」,如果 JavaScript 在關鍵字 return
後發現「Carriage Return」,就會自動補上分號,所以什麼都沒有回傳。
要讓這程式正確執行應該這麼做:
1 | function getPerson(){ |
刪除「Carriage Return」並且補上一個「空白」分隔,讓程式碼好看一點。再補上一個大括號,讓語法解析器知道要開始使用物件實體語法。
詞彙環境指的是程式碼在整個程式中的「實際位置」,在 JavaScript 中詞彙環境非常重要,但不是每個程式語言都是這樣。
在 JavaScript 中我們可能寫了一個 函式 function
1 | function hello() { |
這個函數內有個變數 a
,這個 a
詞彙上就是「在函式內」,但就像上面提到的,我們寫的程式碼需要被轉換成電腦看得懂的東西,也就是在轉換過程中,函式以及變數會被放到一個對應的記憶體位址,以及如何與其他的函式變數、程式互動。
那為什麼 JavaScript 中詞彙環境非常重要呢?
因為這可以幫助編譯器在轉換過程中做出某些決定。
A wrapper to help mange the code that is running.
執行環境就有點像是一個管理者的角色,因為可能會有相當多的「詞彙環境」,但哪一個才是現在正執行的呢?
這就是執行環境所管理的東西。
執行環境包含了我們寫的程式碼、正在執行的程式碼,但不只包含這些,當目前程式碼正處於被編譯中時,編譯器除了執行我們的程式碼之外也能執行別的事情。
在 JavaScript 的世界中,物件是相當重要的,我們必須先了解 JavaScript 中物件的意義。
例如:名稱(name) / 值(value) 配對
一個「名稱 / 值」配對,代表「一個名稱會對應到一個值」
一段正在執行的程式碼,同樣的名稱只會有一個;一個名稱只能被一個值定義,而這個值可以是更多名稱/值的配對。
這樣說很模糊,舉例一個簡單的「名稱 / 值」配對:
1 | var brother = 'Tony' |
這樣我們就有了宣告(var)、變數名稱(brother)、值(Tony)。
那比較複雜的「名稱 / 值」配對呢?會是什麼樣子?
記得剛才提到的物件嗎?在 JavaScript 中我們也可以同樣套用這個概念!
1 | var farm = { |
例如 farm 的值是一個「名稱 / 值」 的組合,farm 仍然是個名稱,但值是用{}包覆住的「名稱 / 值」組合。
繼續往下一層觀察, farmName
、 owner
也是「名稱 / 值」配對,注意到 animals
了嗎?它的值也是一個「名稱 / 值」 的組合。
JavaScript 中的物件,就是在說名稱 / 值配對的組合,而這個值本身可能是另一個物件
總算是把心得記錄完了,這篇主要記錄之後課程可能會多次提到的關鍵字,為了避免之後自己忘記這些關鍵字到底再說什麼,所以特別整理起來,這樣子之後回顧會比較容易。
]]>