การเขียนงานอะซิงโครนัสใน JavaScript สมัยใหม่

เผยแพร่แล้ว: 2022-03-10
สรุปอย่างย่อ ↬ ในบทความนี้ เราจะมาสำรวจวิวัฒนาการของ JavaScript เกี่ยวกับการดำเนินการแบบอะซิงโครนัสในยุคที่ผ่านมา และวิธีที่มันเปลี่ยนวิธีที่เราเขียนและอ่านโค้ด เราจะเริ่มต้นด้วยจุดเริ่มต้นของการพัฒนาเว็บ และไปจนถึงตัวอย่างรูปแบบอะซิงโครนัสสมัยใหม่

JavaScript มีลักษณะสำคัญสองประการในภาษาการเขียนโปรแกรม ทั้งสองสิ่งสำคัญคือการทำความเข้าใจว่าโค้ดของเราทำงานอย่างไร อย่างแรกคือลักษณะการ ซิงโครนั ส ซึ่งหมายความว่าโค้ดจะรันทีละบรรทัด เกือบ ในขณะที่คุณอ่าน และประการที่สองคือเป็น เธรดเดี่ยว คำสั่งเดียวเท่านั้นที่จะถูกดำเนินการเมื่อใดก็ได้

เมื่อภาษาพัฒนาขึ้น สิ่งประดิษฐ์ใหม่ก็ปรากฏขึ้นในที่เกิดเหตุเพื่อให้สามารถดำเนินการแบบอะซิงโครนัสได้ นักพัฒนาได้ลองใช้วิธีการต่างๆ กันในขณะที่แก้ไขอัลกอริทึมและกระแสข้อมูลที่ซับซ้อนมากขึ้น ซึ่งนำไปสู่การเกิดขึ้นของอินเทอร์เฟซและรูปแบบใหม่ที่อยู่รอบตัวพวกเขา

การดำเนินการแบบซิงโครนัสและรูปแบบผู้สังเกตการณ์

ดังที่กล่าวไว้ในบทนำ JavaScript เรียกใช้โค้ดที่คุณเขียนทีละบรรทัด โดยส่วนใหญ่ แม้แต่ในปีแรก ภาษาก็มีข้อยกเว้นสำหรับกฎนี้ แม้ว่าจะมีเพียงไม่กี่กฎและคุณอาจรู้จักอยู่แล้ว: คำขอ HTTP, เหตุการณ์ DOM และช่วงเวลา

 const button = document.querySelector('button'); // observe for user interaction button.addEventListener('click', function(e) { console.log('user click just happened!'); })

หากเราเพิ่มตัวฟังเหตุการณ์ เช่น การคลิกองค์ประกอบและผู้ใช้ทริกเกอร์การโต้ตอบนี้ เอ็นจิ้น JavaScript จะจัดคิวงานสำหรับการเรียกกลับตัวฟังเหตุการณ์ แต่จะดำเนินการสิ่งที่อยู่ในสแต็กปัจจุบันต่อไป หลังจากเสร็จสิ้นด้วยการโทรที่มีอยู่ ตอนนี้จะเรียกใช้การโทรกลับของผู้ฟัง

ลักษณะการทำงานนี้คล้ายกับสิ่งที่เกิดขึ้นกับคำขอของเครือข่ายและตัวจับเวลา ซึ่งเป็นสิ่งประดิษฐ์แรกในการเข้าถึงการดำเนินการแบบอะซิงโครนัสสำหรับนักพัฒนาเว็บ

แม้ว่าสิ่งเหล่านี้จะเป็นข้อยกเว้นของการดำเนินการซิงโครนัสทั่วไปใน JavaScript แต่สิ่งสำคัญคือต้องเข้าใจว่าภาษายังคงเป็นแบบเธรดเดียวและแม้ว่าจะสามารถจัดคิว taks เรียกใช้งานแบบอะซิงโครนัสแล้วกลับไปที่เธรดหลักได้ แต่ก็สามารถรันโค้ดได้เพียงชิ้นเดียวเท่านั้น ขณะนั้น.

เพิ่มเติมหลังกระโดด! อ่านต่อด้านล่าง↓

ตัวอย่างเช่น ลองตรวจสอบคำขอเครือข่าย

 var request = new XMLHttpRequest(); request.open('GET', '//some.api.at/server', true); // observe for server response request.onreadystatechange = function() { if (request.readyState === 4 && request.status === 200) { console.log(request.responseText); } } request.send();

เมื่อเซิร์ฟเวอร์กลับมา งานสำหรับวิธีการที่กำหนดให้กับ onreadystatechange จะถูกจัดคิว (การเรียกใช้โค้ดจะดำเนินต่อไปในเธรดหลัก)

หมายเหตุ : การอธิบายว่ากลไก JavaScript จัดคิวงานและจัดการเธรดการดำเนินการอย่างไรเป็นหัวข้อที่ซับซ้อนที่จะครอบคลุมและอาจสมควรได้รับบทความของตัวเอง ถึงกระนั้น ฉันแนะนำให้ดู “What The Heck Is The Event Loop อย่างไรก็ตาม?” โดย Phillip Roberts เพื่อช่วยให้คุณเข้าใจมากขึ้น

ในแต่ละกรณีที่กล่าวถึง เรากำลังตอบสนองต่อเหตุการณ์ภายนอก ถึงช่วงระยะเวลาหนึ่งแล้ว การดำเนินการของผู้ใช้หรือการตอบสนองของเซิร์ฟเวอร์ เราไม่สามารถสร้างงานแบบอะซิงโครนัสได้ เรามักจะ สังเกตเห็น เหตุการณ์ที่เกิดขึ้นนอกที่เราสามารถเข้าถึงได้

นี่คือสาเหตุที่โค้ดที่มีรูปร่างแบบนี้เรียกว่า Observer Pattern ซึ่งแสดงได้ดีกว่าด้วยอินเทอร์เฟซ addEventListener ในกรณีนี้ ในไม่ช้าเหตุการณ์จะปล่อยไลบรารีหรือเฟรมเวิร์กที่เปิดเผยรูปแบบนี้เฟื่องฟู

Node.js และตัวปล่อยเหตุการณ์

ตัวอย่างที่ดีคือ Node.js ซึ่งหน้าอธิบายตัวเองว่าเป็น “รันไทม์ JavaScript ที่ขับเคลื่อนด้วยเหตุการณ์แบบอะซิงโครนัส” ดังนั้นอีซีแอลอีซีอีอีซีและการเรียกกลับจึงเป็นพลเมืองชั้นหนึ่ง มันยังมีตัวสร้าง EventEmitter ที่ใช้งานแล้ว

 const EventEmitter = require('events'); const emitter = new EventEmitter(); // respond to events emitter.on('greeting', (message) => console.log(message)); // send events emitter.emit('greeting', 'Hi there!');

นี่ไม่เพียงแต่เป็นแนวทางที่ต้องดำเนินการสำหรับการดำเนินการแบบอะซิงโครนัสเท่านั้น แต่ยังเป็นรูปแบบหลักและแบบแผนของระบบนิเวศด้วย Node.js ได้เปิดศักราชใหม่ของการเขียน JavaScript ในสภาพแวดล้อมที่แตกต่าง — แม้นอกเว็บ ด้วยเหตุนี้ สถานการณ์แบบอะซิงโครนัสอื่นๆ จึงเป็นไปได้ เช่น การสร้างไดเร็กทอรีใหม่หรือการเขียนไฟล์

 const { mkdir, writeFile } = require('fs'); const styles = 'body { background: #ffdead; }'; mkdir('./assets/', (error) => { if (!error) { writeFile('assets/main.css', styles, 'utf-8', (error) => { if (!error) console.log('stylesheet created'); }) } })

คุณอาจสังเกตเห็นว่าการเรียกกลับได้รับ error เป็นอาร์กิวเมนต์แรก หากคาดหวังข้อมูลการตอบกลับ ก็จะไปเป็นอาร์กิวเมนต์ที่สอง สิ่งนี้เรียกว่า Error-first Callback Pattern ซึ่งกลายเป็นข้อตกลงที่ผู้เขียนและผู้มีส่วนร่วมนำมาใช้สำหรับแพ็คเกจและไลบรารีของตนเอง

สัญญาและห่วงโซ่การโทรกลับที่ไม่มีที่สิ้นสุด

เมื่อการพัฒนาเว็บประสบปัญหาที่ซับซ้อนมากขึ้นในการแก้ไข ความต้องการสิ่งประดิษฐ์แบบอะซิงโครนัสที่ดีขึ้นก็ปรากฏขึ้น หากเราดูข้อมูลโค้ดสุดท้าย เราจะเห็นการโยงการโทรกลับซ้ำๆ ซึ่งไม่ขยายขนาดและจำนวนงานเพิ่มขึ้น

ตัวอย่างเช่น ให้เพิ่มอีกสองขั้นตอนเท่านั้น การอ่านไฟล์และการประมวลผลรูปแบบล่วงหน้า

 const { mkdir, writeFile, readFile } = require('fs'); const less = require('less') readFile('./main.less', 'utf-8', (error, data) => { if (error) throw error less.render(data, (lessError, output) => { if (lessError) throw lessError mkdir('./assets/', (dirError) => { if (dirError) throw dirError writeFile('assets/main.css', output.css, 'utf-8', (writeError) => { if (writeError) throw writeError console.log('stylesheet created'); }) }) }) })

เราสามารถเห็นได้ว่าโปรแกรมที่เรากำลังเขียนมีความซับซ้อนมากขึ้น โค้ดติดตามยากขึ้นสำหรับสายตามนุษย์เนื่องจากมีการโยงการโทรกลับหลายครั้งและการจัดการข้อผิดพลาดซ้ำๆ

คำสัญญา กระดาษห่อ และลวดลายลูกโซ่

Promises ไม่ได้รับความสนใจมากนักเมื่อมีการประกาศครั้งแรกว่าเป็นส่วนเสริมใหม่ของภาษา JavaScript ไม่ใช่แนวคิดใหม่เนื่องจากภาษาอื่นมีการใช้งานที่คล้ายคลึงกันเมื่อหลายสิบปีก่อน ความจริงก็คือ พวกเขาเปลี่ยนความหมายและโครงสร้างของโปรเจ็กต์ส่วนใหญ่ที่ฉันทำไปมากตั้งแต่ปรากฏตัว

Promises ไม่เพียงแต่แนะนำโซลูชันในตัวสำหรับนักพัฒนาในการเขียนโค้ดแบบอะซิงโครนัสเท่านั้น แต่ยังเปิดเวทีใหม่ในการพัฒนาเว็บซึ่งทำหน้าที่เป็นฐานการสร้างคุณสมบัติใหม่ในภายหลังของข้อมูลจำเพาะของเว็บ เช่น fetch

การย้ายเมธอดจากวิธีการเรียกกลับเป็นวิธีการแบบอิงตามสัญญากลายเป็นเรื่องปกติมากขึ้นเรื่อยๆ ในโครงการ (เช่น ไลบรารีและเบราว์เซอร์) และแม้แต่ Node.js ก็เริ่มย้ายไปยังพวกเขาอย่างช้าๆ

ตัวอย่างเช่น ห่อเมธอด readFile ของโหนด:

 const { readFile } = require('fs'); const asyncReadFile = (path, options) => { return new Promise((resolve, reject) => { readFile(path, options, (error, data) => { if (error) reject(error); else resolve(data); }) }); }

ที่นี่ เราปิดบังการเรียกกลับโดยดำเนินการภายใน Constructor ของ Promise การเรียกการ resolve เมื่อผลลัพธ์ของเมธอดสำเร็จ และ reject เมื่อมีการกำหนดอ็อบเจ็กต์ข้อผิดพลาด

เมื่อเมธอดส่งกลับ Promise object เราสามารถทำตามความละเอียดที่สำเร็จโดยส่งฟังก์ชันไปที่ then อาร์กิวเมนต์ของมันคือค่าที่คำมั่นสัญญาได้รับการแก้ไข ในกรณีนี้ data

หากมีข้อผิดพลาดเกิดขึ้นระหว่างเมธอด ฟังก์ชัน catch จะถูกเรียกใช้ หากมี

หมายเหตุ : หากคุณต้องการเข้าใจในเชิงลึกมากขึ้นว่า Promises ทำงานอย่างไร ฉันขอแนะนำบทความ “JavaScript Promises: An Introduction” ของ Jake Archibald ซึ่งเขาเขียนไว้ในบล็อกการพัฒนาเว็บของ Google

ตอนนี้เราสามารถใช้วิธีใหม่เหล่านี้และหลีกเลี่ยงสายเรียกกลับได้

 asyncRead('./main.less', 'utf-8') .then(data => console.log('file content', data)) .catch(error => console.error('something went wrong', error))

มีวิธีดั้งเดิมในการสร้างงานอะซิงโครนัสและอินเทอร์เฟซที่ชัดเจนในการติดตามผลลัพธ์ที่เป็นไปได้ ทำให้อุตสาหกรรมสามารถย้ายออกจากรูปแบบผู้สังเกตการณ์ได้ โค้ดที่ใช้คำมั่นสัญญาดูเหมือนจะแก้ปัญหาโค้ดที่อ่านไม่ได้และมีแนวโน้มที่จะเกิดข้อผิดพลาด

เนื่องจากการเน้นไวยากรณ์ที่ดีขึ้นหรือข้อความแสดงข้อผิดพลาดที่ชัดเจนขึ้นช่วยในขณะเขียนโค้ด โค้ดที่เข้าใจง่ายขึ้นจะทำให้นักพัฒนาสามารถอ่านโค้ดได้ง่ายขึ้น ด้วยภาพที่ดีขึ้นของเส้นทางการดำเนินการ จะทำให้จับข้อผิดพลาดที่อาจเกิดขึ้นได้ง่ายขึ้น

การนำ Promises ไปใช้นั้นเป็นสากลในชุมชนที่ Node.js ปล่อยเมธอด I/O เวอร์ชันในตัวอย่างรวดเร็วเพื่อส่งคืนอ็อบเจ็กต์ Promise เช่น การนำเข้าการดำเนินการไฟล์จาก fs.promises

มันยังให้ promisify util เพื่อล้อมฟังก์ชันใด ๆ ที่เป็นไปตามรูปแบบการโทรกลับที่ผิดพลาดเป็นอันดับแรกและแปลงเป็นรูปแบบตามสัญญา

แต่ Promises ช่วยได้ในทุกกรณีหรือไม่?

มาลองจินตนาการถึงงานการประมวลผลล่วงหน้าสไตล์ของเราที่เขียนด้วย Promises กัน

 const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') readFile('./main.less', 'utf-8') .then(less.render) .then(result => mkdir('./assets') .then(() => writeFile('assets/main.css', result.css, 'utf-8')) ) .catch(error => console.error(error))

มีการลดความซ้ำซ้อนในโค้ดอย่างชัดเจน โดยเฉพาะอย่างยิ่งเกี่ยวกับการจัดการข้อผิดพลาดในขณะที่เราพึ่งพา catch แต่ Promises ล้มเหลวในการส่งการเยื้องโค้ดที่ชัดเจนซึ่งเกี่ยวข้องโดยตรงกับการดำเนินการต่อกัน

สิ่งนี้ทำได้จริงในคำสั่งแรก then หลังจากเรียก readFile สิ่งที่เกิดขึ้นหลังจากบรรทัดเหล่านี้คือความจำเป็นในการสร้างขอบเขตใหม่ที่เราสามารถสร้างไดเร็กทอรีก่อน เพื่อเขียนผลลัพธ์ลงในไฟล์ในภายหลัง ซึ่งทำให้เกิด การแบ่ง จังหวะการเยื้อง ซึ่งทำให้ง่ายต่อการกำหนดลำดับของคำสั่งในแวบแรก

วิธีแก้ปัญหาคือการเตรียมวิธีการแบบกำหนดเองที่เตรียมไว้ล่วงหน้าซึ่งจัดการสิ่งนี้และอนุญาตให้มีการต่อเมธอดที่ถูกต้อง แต่เราจะแนะนำความซับซ้อนอีกขั้นให้กับโค้ดที่ดูเหมือนว่าจะมีสิ่งที่จำเป็นเพื่อให้บรรลุเป้าหมาย พวกเราต้องการ.

หมายเหตุ : ลองนับดู นี่เป็นโปรแกรมตัวอย่าง และเราควบคุมวิธีการบางอย่างได้ และพวกเขาทั้งหมดเป็นไปตามอนุสัญญาอุตสาหกรรม แต่ก็ไม่เป็นเช่นนั้นเสมอไป ด้วยการต่อกันที่ซับซ้อนมากขึ้นหรือการแนะนำไลบรารีที่มีรูปร่างแตกต่างกัน สไตล์โค้ดของเราสามารถแตกหักได้ง่าย

น่ายินดีที่ชุมชน JavaScript ได้เรียนรู้อีกครั้งจากรูปแบบภาษาอื่น ๆ และเพิ่มโน้ตที่ช่วยได้มากในกรณีเหล่านี้ ซึ่งการต่องานแบบอะซิงโครนัสไม่น่าพอใจหรือตรงไปตรงมาในการอ่านเหมือนโค้ดซิงโครนัส

Async And Await

Promise ถูกกำหนดให้เป็นค่าที่ไม่ได้รับการแก้ไข ณ เวลาดำเนินการ และการสร้างอินสแตนซ์ของ Promise เป็นการเรียกสิ่งประดิษฐ์นี้ อย่างชัดเจน

 const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') readFile('./main.less', 'utf-8') .then(less.render) .then(result => mkdir('./assets') .then(() => { writeFile('assets/main.css', result.css, 'utf-8') })) .catch(error => console.error(error))

ภายในวิธีการแบบอะซิงโครนัส เราสามารถใช้คำสงวนไว้ await เพื่อกำหนดความละเอียดของ Promise ก่อนที่จะดำเนินการต่อไป

มาทบทวนหรือตัวอย่างโค้ดโดยใช้ไวยากรณ์นี้กัน

 const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less') async function processLess() { const content = await readFile('./main.less', 'utf-8') const result = await less.render(content) await mkdir('./assets') await writeFile('assets/main.css', result.css, 'utf-8') } processLess()

หมายเหตุ : โปรดสังเกตว่า เราจำเป็นต้องย้ายโค้ดทั้งหมดของเราไปยังเมธอด เนื่องจากเราไม่สามารถใช้ await นอกขอบเขตของฟังก์ชัน async ได้ในวันนี้

ทุกครั้งที่เมธอด async พบคำสั่ง await คำสั่งจะหยุดดำเนินการจนกว่าค่าที่ดำเนินการหรือคำสัญญาจะได้รับการแก้ไข

มีผลที่ชัดเจนจากการใช้สัญกรณ์ async/await แม้ว่าจะมีการดำเนินการแบบอะซิงโครนัส โค้ดก็ดูราวกับว่ามันเป็น แบบซิงโครนั ส ซึ่งเป็นสิ่งที่เรานักพัฒนาคุ้นเคยกับการดูและให้เหตุผลมากกว่า

แล้วการจัดการข้อผิดพลาดล่ะ? สำหรับมันเราใช้ข้อความที่มีอยู่เป็นเวลานานในภาษา try catch

 const { mkdir, writeFile, readFile } = require('fs').promises; const less = require('less'); async function processLess() { try { const content = await readFile('./main.less', 'utf-8') const result = await less.render(content) await mkdir('./assets') await writeFile('assets/main.css', result.css, 'utf-8') } catch(e) { console.error(e) } } processLess()

เรามั่นใจได้ว่าข้อผิดพลาดที่เกิดขึ้นในกระบวนการจะได้รับการจัดการโดยรหัสภายในคำสั่ง catch เรามีศูนย์กลางที่ดูแลการจัดการข้อผิดพลาด แต่ตอนนี้ เรามีโค้ดที่อ่านและปฏิบัติตามได้ง่ายขึ้น

การมีการกระทำที่เป็นผลตามมาซึ่งคืนค่าไม่จำเป็นต้องเก็บไว้ในตัวแปรเช่น mkdir ที่ไม่ทำลายจังหวะของโค้ด ไม่จำเป็นต้องสร้างขอบเขตใหม่เพื่อเข้าถึงมูลค่าของ result ในขั้นตอนต่อไป

พูดได้อย่างปลอดภัยว่า Promises เป็นสิ่งประดิษฐ์พื้นฐานที่นำมาใช้ในภาษา ซึ่งจำเป็นต่อการเปิดใช้งานสัญกรณ์ async/await ใน JavaScript ซึ่งคุณสามารถใช้กับทั้งเบราว์เซอร์รุ่นใหม่และ Node.js เวอร์ชันล่าสุด

หมายเหตุ : เมื่อเร็วๆ นี้ใน JSConf Ryan Dahl ผู้สร้างและผู้มีส่วนร่วมคนแรกของ Node เสียใจที่ไม่ได้ยึดติดกับ Promises ในการพัฒนาช่วงแรกๆ ส่วนใหญ่ เนื่องจากเป้าหมายของ Node คือการสร้างเซิร์ฟเวอร์ที่ขับเคลื่อนด้วยเหตุการณ์และการจัดการไฟล์ซึ่งรูปแบบ Observer ทำงานได้ดีกว่า

บทสรุป

การแนะนำ Promises ในโลกของการพัฒนาเว็บได้เปลี่ยนวิธีที่เราจัดคิวการดำเนินการในโค้ดของเรา และเปลี่ยนวิธีที่เราให้เหตุผลเกี่ยวกับการเรียกใช้โค้ดของเรา และวิธีที่เราเขียนไลบรารีและแพ็คเกจต่างๆ

แต่การย้ายออกจากสายการเรียกกลับนั้นแก้ไขได้ยากกว่า ฉันคิดว่าการต้องผ่านวิธีการ then ไม่ได้ช่วยให้เราย้ายออกจากขบวนความคิดหลังจากคุ้นเคยกับรูปแบบผู้สังเกตการณ์มาหลายปีและแนวทางที่ผู้ค้ารายใหญ่นำมาใช้ ในชุมชนเช่น Node.js

ดังที่โนแลน ลอว์สันกล่าวไว้ในบทความที่ยอดเยี่ยมของเขาเกี่ยวกับการใช้ผิดๆ ในการต่อคำสัญญา นิสัยการโทรกลับแบบเก่านั้นตายยาก ! ต่อมาเขาอธิบายวิธีที่จะหลีกหนีจากหลุมพรางเหล่านี้

ฉันเชื่อว่า Promises จำเป็นสำหรับขั้นตอนกลางเพื่อให้สามารถสร้างงานแบบอะซิงโครนัสได้อย่างเป็นธรรมชาติ แต่ไม่ได้ช่วยให้เราก้าวไปข้างหน้าในรูปแบบโค้ดที่ดีขึ้นได้มากนัก บางครั้งคุณต้องการไวยากรณ์ภาษาที่ปรับเปลี่ยนและปรับปรุงได้จริง ๆ

ขณะที่เราพยายามไขปริศนาที่ซับซ้อนมากขึ้นโดยใช้ JavaScript เราเห็นว่าจำเป็นต้องมีภาษาที่เป็นผู้ใหญ่มากขึ้น และเราทดลองกับสถาปัตยกรรมและรูปแบบที่เราไม่เคยเห็นบนเว็บมาก่อน

เรายังไม่ทราบว่าข้อมูลจำเพาะของ ECMAScript จะเป็นอย่างไรในช่วงหลายปีที่ผ่านมา เนื่องจากเราขยายการกำกับดูแล JavaScript ภายนอกเว็บอยู่เสมอ และพยายามไขปริศนาที่ซับซ้อนยิ่งขึ้น

ยากที่จะบอก ว่า เราต้องการอะไรจากภาษาสำหรับปริศนาเหล่านี้บางตัวเพื่อเปลี่ยนเป็นโปรแกรมที่ง่ายกว่า แต่ฉันพอใจกับวิธีที่เว็บและ JavaScript กำลังเคลื่อนย้ายสิ่งต่าง ๆ พยายามปรับให้เข้ากับความท้าทายและสภาพแวดล้อมใหม่ ฉันรู้สึกว่าตอนนี้ JavaScript เป็น สถานที่ที่เป็นมิตรแบบอะซิงโครนัส มากกว่าตอนที่ฉันเริ่มเขียนโค้ดในเบราว์เซอร์เมื่อกว่าทศวรรษที่แล้ว

อ่านเพิ่มเติม

  • “สัญญาจาวาสคริปต์: บทนำ” เจค อาร์ชิบัลด์
  • “Promise Anti-Patterns” เอกสารประกอบของห้องสมุด Bluebird
  • “เรามีปัญหากับคำสัญญา” โนแลน ลอว์สัน