# 0421 透過 JavaScript 進行 HTTP 請求 & 非同步 ###### tags: `JavaScript` - [老師講義](https://hackmd.io/@pastleo/Byqk5WeUO#/) - [隨堂練習包](https://ppt.cc/f514zx) ## HTTP request (請求) - 網頁的運作:[HTTP Wiki 介紹](https://zh.wikipedia.org/wiki/%E8%B6%85%E6%96%87%E6%9C%AC%E4%BC%A0%E8%BE%93%E5%8D%8F%E8%AE%AE) - DevTool 的 Network 可以拿來觀察 - 網站重新整理後跑出來的每一行都是一個瀏覽器送出的 HTTP Request - 打開 Header 觀察細節 - 裡面會有 General, Response Header, Request Header - 一般 Request Header 會有:`cookie` - Response 也會有新的 cookie 夾帶一起送回來 ![](https://i.imgur.com/OvjUHcB.png) - 電腦跟電腦溝通就像寄信傳輸一樣 - 信封上有既定的慣例寫寄件者、收件者、地址.... - 電腦跟電腦間有特別的通訊協定 - Header 就像信封上寫的東西一樣 - 網站的 Response ![](https://i.imgur.com/DTTIDuI.png) - 利用 JS 寫程式跟後端拿資料 - 前端需要即時跟後端拿資料再即時渲染到目前的網頁 ## HTTP request via JavaScript ### fetch API - fetch 字面上意思:==拿取== - [MDN 介紹](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) - 在 fetch(...) 出現之前 - 使用 `$.ajax()` - `XMLHttpRequest` #### 練習用的 Posts API server - [練習使用](https://pastleo-posts-api.herokuapp.com/) - [Git Repo](https://github.com/5xTraining/pastleo-js-posts-api) ### Web API 常見的回傳格式:JSON - [JSON維基百科](https://zh.wikipedia.org/wiki/JSON) - 提供一個結構化的資料給你看 - 方便 JavaScript 處理資料 ```json= [ { "id": 128, "title": "hello json!" } ] ``` - 範例: ![](https://i.imgur.com/EfABCkA.png) - 要小心 JSON 有一些不合常規的格式 - 單引號不接受,只可用雙引號(`" "`) - 不能接受任何運算(function) - 只能寫計算完成的值 - 字串、數字、陣列、物件 - 大部分是由程式產生的 - `Expecting 'STRING', 'NUMBER', 'NULL', 'TRUE', 'FALSE', '{', '[', got 'undefined'` - JS 及 JSON 間轉換 - 用 `JSON.stringify()` 把 JS 裡的物件、陣列、字串或數字轉換為 JSON 格式 - - [MDN 說明](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify) - 用 `JSON.parse()` 把 JSON 格式的東西轉回 JS 格式 - - [MDN 說明](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse) ![](https://i.imgur.com/m4wwCHN.png =400x) - [JSON-Validator / JSON 格式驗證](https://jsonlint.com/) - JSON 是 JS 內建的 API ### 實作:fetch-first-post - 網頁讀取完之後用 fetch 把各個資料塞進去 - 任務:使用 fetch 抓取並顯示 API 回傳的第一篇文章 - fetch 資料 (JavaScript 一個在 window 底下的 function) ```javascript= fetch('...放一段字串 / 網址') // 網址也要用字串模式 .then(request => request.json()) .then(posts => { ... }) ``` - 試著從 api 裡面抓取所有資料 ```javascript= document.addEventListener('DOMContentLoaded', function(){ fetch('https://pastleo-posts-api.herokuapp.com/api/posts') .then(request => request.json()) .then(posts => { console.log(posts) }) }) ``` - 拿到的資料是陣列 - 把拿到的東西塞到 HTML 裡面 ```javascript= document.addEventListener('DOMContentLoaded', function(){ fetch('https://pastleo-posts-api.herokuapp.com/api/posts') .then(request => request.json()) .then(posts => { console.log(posts) const firstPost = posts[0] const postTitleA = document.querySelector('.post-title') postTitleA.textContent = firstPost.title postTitleA.href = firstPost.url document.querySelector('.post-created-at').textContent = firstPost.created_at document.querySelector('.post-author').textContent = firstPost.author document.querySelector('.post-description').textContent = firstPost.description const postLinkA = document.querySelector('.post-link') postLinkA.classList.remove('hidden') postLinkA.href = firstPost.url }) }) ``` ### CORS 跨來源資源共享 - Cross-origin resource sharing - [WIKI 介紹](https://zh.wikipedia.org/zh-tw/%E8%B7%A8%E4%BE%86%E6%BA%90%E8%B3%87%E6%BA%90%E5%85%B1%E4%BA%AB) ![](https://i.imgur.com/qWkeneO.png) * CORS headers 設定 * 伺服器預設為沒有設定(不開放) * 設定完之後 server 就知道可以放哪個 domain 的人進來拿資料 * 看設定的 DNS 位址 * [西瓜範例](https://github.com/5xTraining/pastleo-js-posts-api/blob/bdcb851fb392b20179f9521584d67f35af9d37ec/app/controllers/posts_controller.rb#L99) * 原始碼把 allow_cors 放在 before_action 所以都會執行 ![](https://i.imgur.com/wPF2bro.png) ![](https://i.imgur.com/LrNq0x8.png) * 一般狀況:沒有 CORS headers * [西瓜範例(Posts 另一組沒有 CORS 的 API)](https://pastleo-posts-api.herokuapp.com/posts.json) * 其實伺服器還是會完成所有動作,只是會擋掉 JS 拿資料的動作 * 實際通常不會直接寫進 controller 裡面 * 實務上通常會直接寫進 GEM 裡面 => 直接把 CORS 允許名單做進 Route 裡面 ## Asynchronous / 非同步 - 什麼是非同步? - 等待的時候可以去處理別的事情 ### 什麼是同步? - 第一行執行完,執行第二行。第二行執行完,執行第三行。 ```json= const two = 1 + 1; const post = fetchPostSync(); // IO operation postTitleA.textContent = post.title; ``` - 範例中,第二行會需要比較久的時間執行 #### 同步的問題 ![範例圖片](https://i.imgur.com/3T0GM9z.png) - 同步沒有回應的原因 - JS Event Loop 的特性: - 單執行緒 - [Run-to-completion](https://developer.mozilla.org/en-US/docs/Web/JavaScript/EventLoop#run-to-completion) - 像超商店員服務完前一個客人以後,才會服務你 - input/output (IO) operation: - 存取網路、檔案等,通常 CPU 不忙,但是需要等待時間 ### 同步及非同步案例 - 使用 07_sync-async-fetch - 20 行 setInterval 每秒會執行一次 - 24 行如果執行下去,程式就會卡在那無法繼續進行 - 33 行傳出 false,34 行送出之後一樣會卡住(照樣同步) - 40 行傳出 true 就是開啟非同步行為(fetch 預設為 true) - 因為 40 行是非同步,所以要設 42 行的 callback 接他的結果,因為在 41 行結束時結果還沒回來 ![](https://i.imgur.com/QAjKDHR.png) - fetch 不允許同步的事件 - 給一個 function 來告知瀏覽器 IO 完成的時候要做什麼 - 非同步寫法 - ![非同步](https://i.imgur.com/nJ9S7yG.png) * Callback hell ```javascript= const xhr1 = new XMLHttpRequest(); xhr1.open('GET', /* ... */); xhr1.send(); xhr1.addEventListener('load',() => { const xhr2 = new XMLHttpRequest(); xhr2.open('GET', /* ... */); xhr2.send(); xhr2.addEventListener('load',() => { const xhr3 = new XMLHttpRequest(); xhr3.open('GET', /* ... */); xhr3.send(); xhr3.addEventListener('load',() => { const xhr4 = new XMLHttpRequest(); xhr4.open('GET', /* ... */); xhr4.send(); xhr4.addEventListener('load',() => { // ... }) }) }) }) ``` ### 非同步寫法 Promise - [MDN 介紹](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) - [MDN 介紹 Promise catch](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch) - promise 有點像是一個未來的事件 - 他保證未來會發回來給你 - 我承諾你未來會有 - 發明了 promise 之後,制定標準的人就把一些需要非同步執行的指令都加上 promise - `fetch` 就會回傳 `promise` - JavaScript 官方的非同步處理介面 - `.then(callback)` - 在 resolve 時執行 callback - `callback` 如果回傳 `Promise` - 可以繼續串 (chain) - `then(callback).then(callback)...` - 預設 callback 裡的 .json() 會回傳一個 promise - [callback西瓜範例](https://hackmd.io/eobvUTY_TK2ZPJdxJysNEQ#Callback) #### 實作範例 - 使用 08_fetch-first-post-content - 取得文章列表之後,再抓文章內文來顯示 - `.json()` - 接收網站的 response 再把資料從 json 格式轉成 js 格式 - 會回傳一個包含 js 內容的 `promise` * 不好的寫法:(再次陷入callback hell) ```javascript= fetch('https://pastleo-posts-api.herokuapp.com/api/posts') .then(function(response){ response.json().then(function(response){ response...... }) }) ``` * 比較好的方法: * .then 最後面加 return * 後面 .then 分開來放 ```javascript= document.addEventListener('DOMContentLoaded', () => { const descriptionDiv = document.querySelector('.post-description') fetch('https://pastleo-posts-api.herokuapp.com/api/posts') .then(response => response.json()) .then(posts => { const post = posts[0]; document.querySelector('.post-created-at').textContent = post.created_at; document.querySelector('.post-author').textContent = `By ${post.author}`; const postTitleA = document.querySelector('a.post-title'); postTitleA.textContent = post.title; postTitleA.href = post.url; descriptionDiv.textContent = post.description; return fetch(post.api_url) }) .then(response => response.json()) .then(fullPost => { document.querySelector('.post-content').textContent = fullPost.content descriptionDiv.classList.add('hidden') }) }); ``` ### 非同步寫法 async / await - 只是把寫法改掉,運作模式跟效能都是一樣的 - [MDN 介紹 async](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function) - 用 async 的 function 一定會回傳 promise - 只有 async function 才能用 await - 寫法 ```json= async () => {} async function() {} async function name() {} ``` - 一律回傳 Promise - 可用 await 關鍵字 - 加入 await 就像 fetch 後面有 .then 一樣 - 有點像 `接著要執行的東西 = await 要執行比較久的東西` ```javascript= resolved = await promise ``` - 改寫之前的程式碼 ```javascript= document.addEventListener('DOMContentLoaded', async () => { // 加入 sync const descriptionDiv = document.querySelector('.post-description') const postsResponse = await fetch('https://pastleo-posts-api.herokuapp.com/api/posts') const posts = await postsResponse.json() const post = posts[0]; document.querySelector('.post-created-at').textContent = post.created_at; document.querySelector('.post-author').textContent = `By ${post.author}`; const postTitleA = document.querySelector('a.post-title'); postTitleA.textContent = post.title; postTitleA.href = post.url; descriptionDiv.textContent = post.description; const fullPostResponse = await fetch(post.api_url) const fullPost = await fullPostResponse.json() document.querySelector('.post-content').textContent = fullPost.content descriptionDiv.classList.add('hidden') }); ``` - 但是... await 會有出狀況的時候 - 在送出表單(`await fetch`)前沒有 `event.preventDefault()` - 這樣表單就會依原本的方式送出,會無法得到預期的非同步執行結果 - 瀏覽器會以為已經做完了,所以就執行預設程序 - 如果有舊版網頁沒有使用 async 跟 await 時,最好在新區塊加入新功能就好,不要改寫舊的部分,避免造成不相容或壞掉的狀況發生 --- ## HTTP `POST` via JavaScript - 用了之後可以把東西送出去 - Header 裡面會出現 Form Data - [MDN 說明 fetch 相關參數](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) - fetch(url, options) - url 與 posts index 相同 - options - `method: 'POST'` - `headers: { Authorization: '...' }` - 例:pastleo-js-posts-api-secret - 讓伺服器知道你是誰,用於認證或篩選使用者 - 輸入錯誤的話會顯示`422`訊息,顯示認證失敗 - 實務上會是隨機產生的內容 - `body: new FormData(form)` - FormData - 收集 form 裡面的資料 - 對照 input tag 裡面的 name 產生對應 - [MDN 說明](https://developer.mozilla.org/en-US/docs/Web/API/FormData/FormData) #### 範例說明 * FormData ![](https://i.imgur.com/XG2pZQS.png) * 網站建置時限制存取的人 ![](https://i.imgur.com/yDmOJbS.png) * 使用 09_create_post 示範 - 錯誤處理(顯示錯誤訊息) ```javascript= document.addEventListener('DOMContentLoaded', () => { const form = document.getElementById('create-post-form'); const submitBtn = form.querySelector('button[type=submit]'); form.addEventListener('submit', async event => { event.preventDefault(); form.querySelectorAll('label').forEach( label => label.querySelector('.error').textContent = '' ) const response = await fetch('https://pastleo-posts-api.herokuapp.com/api/posts', { method: 'POST', headers: { Authorization: 'pastleo-js-posts-api-secret' }, body: new FormData(form) } ) if (response.ok) { submitBtn.textContent = 'Success!' } else { submitBtn.textContent = 'Error!' const errors = await response.json() form.querySelectorAll('label').forEach( label => { const error = errors[label.htmlFor] if (error) { label.querySelector('.error').textContent = error.join(', ') } } ) } }); }) ``` - Loading 時 button 會有變色效果 ## 作業 - 用 new Promise 改寫 setTimeout - 可參考 [MDN 說明 promise constructor](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Promise) * 改寫內容: ```javascript= -setTimeout(() => { - submitBtn.textContent = 'Submit'; - submitBtn.disabled = false; -}, 2000); +await timeoutPromise(2000); +submitBtn.textContent = 'Submit' +submitBtn.disabled = false; ``` ## 題外話 ### 開發者工具(右鍵檢查) - 平常沒開的時候是不會記錄東西的 - 因為會浪費記憶體 - 打開之後會是空白 - 打開後按重新整理就會有東西跑出來 - Disable Cache (開發時隨時更新) ![](https://i.imgur.com/Llm6Pa3.png) - 模擬比較慢的手機網路(像是 Slow 3G) ![](https://i.imgur.com/jMcoIFj.png) - 選取特定的 Request/Response ![](https://i.imgur.com/JYGKGHB.png) - 找出物件裡包裹的東西 - 在 Element view 裡面可以點到物件 - 在 console 裡打 `$0` - 會顯示出現在點到的東西跟詳細屬性 --- ### 取得使用者輸入數值 - 一個同步執行的內建方法 ```javascript= const input = prompt(); // prompt 回傳值為輸入框得到的資料 putBlock('Prompt:', input); // purBlock 自動產出一個區塊 ``` --- ### Arrow function [MDN 介紹](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions) - function 裡可以?? - function 如果只有一個參數,`()`可以省略 ```javascript= // 傳統函式宣告 function asdf(a){ return a + 1 } // asdf 變數宣告接一個匿名函式 let asdf = function (a) { return a + 1 } // 變成 arrow function ( 把asdf放到前面 ) let asdf = (a) => { return a + 1 } // 省略花括號跟 return asdf = (a) => a + 1 // 如果只有一個參數的話,小括號可以省略 asdf = a => a + 1 ``` --- ### Template Strings Template literals are enclosed by the backtick (`) (grave accent) character instead of double or single quotes. - 裡面可以放運算式 - Ruby: `"qwer #{....}"` - JavaScript: `"qwer ${....}"` > [MDN對Template strings的解釋](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals)