Bài viết được sự cho phép của tác giả Tống Xuân Hoài
Sẽ thật là thiếu sót nếu không nói về Promise trong JavaScript. Thực tế, các bài viết về Promise đã có rất nhiều người viết, bạn đọc có thể tìm thấy chúng bằng Google hoặc thi thoảng lại bắt gặp trên một hội nhóm có liên quan đến lập trình nào đó. Nhưng vì Promise là một kiến thức quan trọng và mỗi người lại có cách truyền đạt khác nhau do vậy đội ngũ TopDev đã cho ra đời bài viết này để đi sâu vào Promise là gì? một vài lưu ý và cả những nhầm lẫn có thể có. Qua đó giúp cho bạn đọc hình dung và tránh được một số lỗi trong quá trình coding của mình.
Promise JS là gì?
Promise là một cơ chế trong JavaScript giúp bạn thực thi các tác vụ bất đồng bộ mà không rơi vào callback hell hay pyramid of doom, là tình trạng các hàm callback lồng vào nhau ở quá nhiều tầng.
Hay nói cách khác, Promise JavaScript là một đối tượng đại diện cho kết quả của một hoạt động bất đồng bộ (asynchronous) trong tương lai.
Cách hoạt động của Promise
Một Promise gồm có 3 trạng thái tương ứng với 3 kết quả của hàm bất đồng bộ.
pending
là trạng thái ban đầu, đang chờ kết quả.fulfilled
là trạng thái xử lý thành công, có kết quả trả về.rejected
là trạng thái thất bại, có thể kèm theo lỗi chi tiết.
Cú pháp tạo một Promise
Để tạo một Promise, bạn sử dụng đối tượng Promise
với một hàm constructor (hàm khởi tạo). Hàm khởi tạo này nhận hai tham số là resolve và reject, dùng để báo cáo khi Promise được giải quyết hoặc bị từ chối.
let myPromise = new Promise(function(resolve, reject) {
// logic bất đồng bộ
let success = true;
if (success) {
resolve("Thành công!"); // Giải quyết Promise nếu thành công
} else {
reject("Thất bại!"); // Từ chối Promise nếu có lỗi
}
});
Ví dụ sử dụng Promise JS
Ví dụ chúng ta tạo ra một Promise, nhận vào một số x
, nếu x
chia hết cho 2 thì trả về trạng thái fulfilled
và ngược lại.
function fn(x) {
return new Promise((resolve, reject) => {
if (x % 2 === 0) {
resolve(true);
} else {
reject(new Error('x is not even'));
}
})
}
Về bản chất, Promise là đại diện cho kết quả trả về trong tương lai. Ở ví dụ trên, chúng ta hoàn toàn không cần thiết phải tạo ra một Promise để xử lý, vì tất cả hành động bên trong hàm fn
đều là đồng bộ. Vậy thì như thế nào là bất đồng bộ cũng như nên tạo ra Promise khi nào?
>> Thế nào là hàm bất đồng bộ? Click xem ngay!
Lấy ví dụ về hành vi thực hiện một truy vấn GET đến địa chỉ https://example.com, lúc này việc xử lý không đơn thuần phụ thuộc vào tốc độ của CPU nữa mà phụ thuộc vào tốc độ mạng của bạn. Mạng càng nhanh, bạn sẽ nhanh chóng có kết quả và ngược lại. Trong JavaScript, chúng ta có hàm fetch
để gửi request, và hàm này là bất đồng bộ, nó trả về một Promise.
fetch('https://example.com');
Để xử lý kết quả của Promise, chúng ta có then
và catch
tương ứng với hai trường hợp thành công và thất bại.
fetch('https://example.com')
.then(res => console.log(res))
.catch(err => console.log(err));
Nếu sau một thời gian xử lý, fetch
có trạng thái fulfilled
, hàm trong then
sẽ được kích hoạt, ngược lại, fetch
có trạng thái rejected
, ngay lập tức hàm trong catch
được thực thi.
Đôi khi bạn sẽ bắt gặp một vài câu hỏi kiểu như là dự đoán kết quả trả về của ví dụ sau:
console.log('1');
fetch('https://example.com')
.then(res => console.log(res))
.catch(err => console.log(err))
.finally(() => console.log('3'));
console.log(‘2’);
Thực ra câu hỏi này nhằm kiểm tra kiến thức của bạn về hành vi bất đồng bộ trong JavaScript. Kết quả in ra là 1, 2, 3 chứ không phải là 1, 3, 2. Điều này xảy ra là do cơ chế xử lý bất đồng bộ, vì kết quả của hành vi bất đồng bộ sẽ được trả về trong tương lai cho nên JavaScript sẽ nghĩ rằng: “OK, hàm bất đồng bộ này chưa có kết quả ngay được, để đó đã, xử lý tiếp các lệnh bên dưới, khi nào hết sạch thì quay lại xem nó có kết quả chưa”
Trong Promise có một số hàm static hữu dụng trong nhiều trường hợp như: all
, allSettled
, any
và race
.
Promise.all
nhận vào một mảng các Promise, nó cũng trả về một Promise và có trạng thái fulfilled
khi tất cả Promise trong mảng đều thành công, ngược lại, nó có trạng thái rejected
khi chỉ cần 1 Promise trong mảng bị thất bại.
Promise.allSettled
cũng tương tự như Promise.all
chỉ có điều nó luôn trả về tất cả kết quả của Promise trong mảng cho dù là thành công hay thất bại. Cả hai Promise.all
và Promise.allSettled
hữu ích trong trường hợp bạn cần chạy nhiều hàm bất đồng bộ ngay lập tức mà không quan trọng thứ tự kết quả.
Trong khi Promise.race
trả về kết quả của Promise được giải quyết nhanh nhất trong mảng, không kể là thành công hay thất bại, thì Promise.any
lại trả về kết quả thành công của Promise có trạng thái fulfilled
đầu tiên trong mảng. race
và any
phù hợp trong các trường hợp bạn có nhiều Promise thực hiện hành động giống nhau và cần một trường hợp dự phòng giữa các kết quả đó.
Việc làm JavaScript Hồ Chí Minh dành cho bạn!
Promise JS thay thế “callback hell”
Thật ngạc nhiên khi biết rằng trước kia JS không có Promise, đúng vậy bạn không nghe nhầm đâu. Mọi tác vụ bất đồng bộ trước kia được xử lý qua callback, chúng ta cần xác định một hàm callback để xử lý kết quả trong tương lai.
Dưới đây là một callback hell điển hình.
api.getUser('pikalong', function(err, user) { if (err) throw err api.getPostsOfUser(user, function(err, posts) { if (err) throw err api.getCommentsOfPosts(posts, function(err, comments) { // vân vân và mây mây... }) }) })
Ví dụ trên khi được viết lại bằng Promise sẽ là:
api.getUser('pikalong') .then(user => api.getPostsOfUser(user)) .then(posts => api.getCommentsOfPosts(posts)) .catch(err => { throw err })
Để tạo ra một promise object thì bạn dùng class Promise có sẵn trong trình duyệt như sau:
const p = new Promise( /* executor */ function(resolve, reject) { // Thực thi các tác vụ bất đồng bộ ở đây, và gọi `resolve(result)` khi tác // vụ hoàn thành. Nếu xảy ra lỗi, gọi đến `reject(error)`. })
Trong đó, executor là một hàm có hai tham số:
- resolve là hàm sẽ được gọi khi promise hoàn thành
- reject là hàm sẽ được gọi khi có lỗi xảy ra
Ví dụ một hàm bất đồng bộ fnA
, nhận vào một hàm callback có chứa 2 tham số data
đại diện cho kết quả khi thành công và err
đại diện cho lỗi. Tương tự là các hàm fnB
, fnC
… Như vậy nếu muốn kết hợp các hàm xử lý này với nhau thì chúng ta phải lồng chúng vào nhau.
fnA((data1, err1) => {
fnB((data2, err2) => {
fnC((data3, err3) => {
….
}
}
}
Khi có quá nhiều callback được viết như vậy, mã của chúng ta sẽ trở thành “địa ngục gọi lại” theo đúng nghĩa đen, gây rối rắm và cản trở quá trình đọc hiểu.
Promise được giới thiệu để mang đến một cách tiếp cận mới trong quá trình xử lý bất đồng bộ. Chúng ta vẫn sẽ có callback nhưng hạn chế được “hell”, vì giờ đây mọi thứ được xử lý nối tiếp nhau thông qua then
.
fnA()
.then(data => fnB(data))
.then(data => fnC(data))
...
.catch(err => console.log(err));
Ngay sau đó, rất nhiều hàm xử lý bất đồng bộ bằng callback trước đó được viết lại bằng cách sử dụng new Promise
. Trong Node.js, chúng ta còn có hẳn một build-in modules để hỗ trợ việc chuyển đổi này là util.promisify. Callback đến bây giờ vẫn được hỗ trợ, tuy nhiên với nhiều lợi ích mà Promise mang lại, nhiều thư viện mới ra đời sử dụng Promise để xử lý bất đồng bộ theo mặc định.
Promise trong các vòng lặp
Có nhiều sai lầm đáng tiếc khi sử dụng Promise mà chưa hiểu rõ bản chất của nó, một trong số đó có thể kể đến như là xử lý bất đồng bộ tuần tự trong vòng lặp.
Giả sử bạn cần lặp qua 5 trang, gọi API phân trang từ 1 -> 5 và ghép dữ liệu trả về theo thứ tự vào trong một mảng.
const results = [];
for (let i = 1; i <= 5; i++) {
fetch('https://example.com?page=' + i)
.then(response => response.json())
.then(data => results.push(data));
}
Đoạn mã trên tạo ra một vòng lặp lấy dữ liệu từ page 1 đến page 5, dữ liệu trả về được đẩy vào trong results
. Thoạt nhìn kết quả sẽ là một mảng dữ liệu theo thứ tự từ trang đầu đến trang cuối, nhưng thực tế, mỗi lần chạy, bạn sẽ thấy dữ liệu trong results
được sắp xếp không theo một quy luật nào cả.
Hãy nhớ lại fetch
, trả về một Promise, đại diện cho một kết quả trong tương lai… Trong khi for
đang cố gắng lặp qua nhanh nhất có thể, có thể xem rằng 5 lệnh fetch
được gọi và bắt đầu chạy “gần như ngay lập tức”. Lúc này, tốc độ CPU không còn quá quan trọng nữa, tốc độ mạng mới là thứ quyết định đến lệnh fetch
nào có kết quả đầu tiên, và ngay khi có, nó lập tức được push
vào results
, chính vì thế mà dữ liệu được thêm vào một cách ngẫu nhiên.
Để giải quyết, có rất nhiều cách. Ví dụ:
const results = [];
fetch("https://example.com?page=1")
.then((response) => response.json())
.then((data) => results.push(data))
.then(() => fetch("https://example.com?page=2"))
.then((response) => response.json())
.then((data) => results.push(data));
…
Thật điên rồ, chẳng ai lại đi viết như thế cả, ví dụ có đến 1000 page thì sẽ thế nào? Đùa thế chứ, ví dụ trên chỉ cố gắng làm sáng tỏ việc chờ request trước đó hoàn thành thì mới xử lý tiếp đến request tiếp theo như thế nào.
Bluebird là một thư viện xử lý Promise rất tốt, nó cung cấp nhiều hàm tiện ích giúp chúng ta làm việc dễ dàng hơn với các hàm bất đồng bộ.
Ví dụ trên viết lại bằng cách sử dụng hàm each
của Bluebird.
const results = [];
Promise.each([1, 2, 3, 4, 5], (i) => {
return fetch('https://example.com?page=' + i)
.then(response => response.json())
.then(data => results.push(data));
});
“Kim tự tháp” Promises
Một lỗi chúng ta hay mắc phải khi mới làm quen với Promise, đó là tạo ra “kim tự tháp” promises như thế này.
api.getUser('pikalong')
.then(user => {
api.getPostsOfUser(user)
.then(posts => {
api.getCommentsOfPosts(posts)
.then(comments => {
console.log(comments)
})
.catch(err => console.log(err))
})
.catch(err => console.log(err))
})
.catch(err => console.log(err))
Lý do vì chúng ta quên mất tính chất liên kết (chaining) của promise, cho phép bên trong hàm resolve
có thể trả về một giá trị đồng bộ hoặc một promise khác. Do đó cách giải quyết là:
api.getUser('pikalong') // Trả về một promise .then(user => api.getPostsOfUser(user)) .then(posts => api.getCommentsOfPosts(posts)) .catch(err => { throw err })
Theo chúng tôi, việc hiểu và sử dụng thành thạo tính liên kết là một trong những điểm QUAN TRỌNG NHẤT khi làm việc với Promise. Khi promise lồng vào nhau từ 2 tầng trở lên thì đã đến lúc bạn phải refactor lại rồi.
3 cách xử lý nhiều Promise cùng lúc
Chạy các Promise tuần tự
Trong trường hợp muốn chạy các promises một cách tuần tự như sơ đồ ở trên, bạn có thể dùng hàm Array.prototype.reduce
.
[promise1, promise2, promise3].reduce(function(currentPromise, promise) { return currentPromise.then(promise) }, Promise.resolve()) // Đoạn ở trên khi được viết dài dòng ra Promise.resolve().then(promise1).then(promise2).then(promise3)
Async/await mang đến giải pháp “xinh đẹp” hơn, cho phép bạn truy xuất đến giá trị của các promises phía trước nếu cần thiết.
async function() { const res1 = await promise1() const res2 = await promise2(res1) const res3 = await promise3(res2) }
Chạy nhiều Promises cùng lúc với Promise.all()
Lại có trường hợp bạn muốn thực thi và lấy ra kết quả của nhiều promises cùng lúc. Giải pháp “ngây thơ” sẽ là dùng vòng lặp, hoặc .forEach
.
const userIds = [1, 2, 3, 4] // api.getUser() là hàm trả về promise const users = [] for (let id of userIds) { api.getUser(id).then(user => ([...users, user])) } console.log(users) // [], oát-đờ-heo?
Lý do là vì khi promise chưa kịp resolve thì dòng console.log
đã chạy rồi. Chúng ta có thể sửa bằng cách dùng Promise.all([promise1, promise2, ...])
. Phương thức này nhận vào một mảng các promises và chỉ resolve khi tất cả các promises này hoàn thành, hoặc reject khi một trong số chúng xảy ra lỗi.
>> Xem thêm: Xử lý bất đồng bộ với Promise.all trong JavaScript
const userIds = [1, 2, 3, 4] Promise.all(usersIds.map(api.getUser)) .then(function(arrayOfResults) { const [user1, user2, user3, user4] = arrayOfResults })
Nếu dùng async/await thì…
async function() { const userIds = [1, 2, 3, 4] const [user1, user2, user3, user4] = await Promise.all(usersIds.map(api.getUser)) }
Promise.race()
Ngoài hai kiểu chạy tuần tự và song song ở trên, chúng ta còn có Promise.race([promise1, promise2, ...])
. Phương thức này nhận vào một mảng các promises và sẽ resolve/reject ngay khi một trong số các promises này hoàn thành/xảy ra lỗi.
Promise.race([ ping('ns1.example.com'), ping('ns2.example.com'), ping('ns3.example.com'), ping('ns4.example.com') ]).then(result => {})
Các lưu ý khi sử dụng Promise
nguồn ảnh: @tapasadhikary
Luôn đưa vào .then()
một hàm
Bạn thử đoán xem đoạn code sau sẽ in ra gì?
Promise.resolve(1) .then(2) .then(console.log)
Câu trả lời là 1
đó. Phương thức .then
đòi hỏi tham số của nó phải là một hàm. Nếu bạn đưa vào .then()
một giá trị, nó sẽ bị bỏ qua, giải thích tại sao đoạn code trên hiển thị 1
. Trường hợp tương tự:
Promise.resolve(1) .then(Promise.resolve(2)) .then(console.log) // 1
Cách giải quyết:
Promise.resolve(1) .then(() => 2) // hoặc như thế này, mặc dù hơi dư thừa .then(() => Promise.resolve(2)) .then(console.log) // 2
Chúng ta sẽ được kết quả như ý.
Cẩn thận với this
khi dùng tham chiếu hàm
Giả sử bạn có đoạn code sau:
const add2 = x => x + 2 Promise.resolve(4).then(result => add2(result))
Hàm onSuccess
không làm gì khác ngoài việc chuyển result
vào cho add2
, nên bạn có thể dùng tham chiếu hàm để đoạn code trên gọn hơn.
Promise.resolve(4).then(add2)
Bạn có thể nghĩ, vậy với phương thức của một đối tượng, ta cũng có thể đưa tham chiếu hàm vào .then()
?
class User { constructor(user) { this.user = user } getUsername() { return this.user.username } } const u = new User({ username: 'pikalong' }) Promise.resolve() .then(u.getUsername) .then(console.log)
Nhưng bạn lại nhận được lỗi sau:
Unhandled rejection:[TypeError: Cannot read property ‘user’ of undefined]
Lý do là vì khi trong strict mode, biến ngữ cảnh this
chỉ được xác định khi trực tiếp gọi phương thức của đối tượng đó, hoặc thông qua .bind()
. Bạn có thể xem giải thích chi tiết hơn ở đây.
Để giải quyết lỗi này, bạn có thể dùng một trong những cách sau:
.then(() => u.getUsername()) // hoặc .then(u.getUsername.bind(u)) // hoặc dùng hàm mũi tên khi khai báo phương thức trong class (cần plugin // `transform-class-properties` của Babel) class User { // ... getUsername = () => { return this.user.username } }
Cẩn thận với return
không tường minh
Xét hai đoạn mã sau:
api.getUser('pikalong') .then(user => { return api.getPostsByUser(user) }) .then(console.log) // posts api.getUser('pikalong') .then(user => { api.getPostsByUser(user) }) .then(console.log) // undefined
Đoạn mã thứ hai trả về undefined
vì trong JavaScript nếu một hàm không công khai trả về một giá trị, undefined
mặc định sẽ được trả về (nguồn). Do đó, bạn cần lưu ý về giá trị return
khi làm việc với Promise.
Phân biệt .then(resolve, reject)
và .then(resolve).catch(reject)
Hàm reject
trong .then(resolve, reject)
chỉ có thể chụp được lỗi từ những .then()
phía trước nó, mà không thể bắt được lỗi xảy ra trong hàm resolve
cùng cấp.
api.getUser('pikalong') .then(user => { throw new Error('Lỗi rồi bạn ei') }, err => { /* Không có gì ở đây cả */ }) api.getUser('pikalong') .then(user => { throw new Error('Lỗi rồi bạn ei') }) .catch(err => console.log(err)) // Chụp được rồi bạn ei
Lưu ý là promise sẽ dừng quá trình thực thi khi bắt được lỗi
Promise.resolve() .then(() => { throw 'foo' }) .then(() => { throw 'bar' }, err => { console.error("here", err) }) .catch(err => console.error('final', err)) // console: // "here bar"
Promise không có cơ chế mặc định truyền dữ liệu
Một trong những yếu điểm của Promise là không có cơ chế mặc định để bạn truyền dữ liệu giữa các promise objects với nhau. Nghĩa là:
api.getUser('pikalong') .then(user => api.getPostsByUser(user)) .then(posts => { // Muốn sử dụng biến user ở trên thì làm sao đây? })
Một cách là dùng Promise.all()
.
api.getUser('pikalong') .then(user => Promise.all([user, api.getPostsByUser(user)])) .then(results => { // Dùng kỹ thuật phân rã biến trong ES6. Bạn lưu ý chúng ta dùng 1 dấu , để // tách ra phần tử thứ hai của mảng mà thôi const [ , posts ] = results // Lại tiếp tục truyền dữ liệu bao gồm [user, posts, comments] xuống promise sau return Promise.all([...results, api.getCommentsOfPosts(posts)]) })
Hoặc, nếu bạn cảm thấy phân tách mảng khó dùng vì phải nhớ thứ tự của các giá trị thì ta có thể dùng object như sau:
api.getUser('pikalong') .then(user => api.getPostsByUser(user).then(posts => ({ user, posts }))) .then(results => api.getCommentsOfPosts(results.posts).then(comments => ({ ...results, comments }))) .then(console.log) // { users, posts, comments }
Lại một lần nữa, async/await lại tỏa sáng vì giúp bạn truy xuất đến kết quả của những promises phía trước.
async function() { const user = await api.getUser('pikalong') const posts = await api.getPostsOfUser(user) const comments = await api.getCommentsOfPosts(posts) }
Cẩn thận nha, Promise không lazy
Với đoạn code sau:
console.log('before') const promise = new Promise(function fn(resolve, reject) { console.log('hello') // ... }); console.log('after')
Kết quả được in ra console lần lượt sẽ là:
before hello after
Bạn có thể thấy hàm executor
của Promise được thực thi ngay lập tức. Điều này có thể dẫn đến những kết quả không mong muốn, chẳng hạn như:
const getUsers = new Promise((resolve, reject) => { return http.get(`/api`, (err, result) => err ? reject(err) : resolve(result)) }) button.onclick = e => getUsers
Cách giải quyết là đưa vào một hàm trả về promise.
const getUsers = () => new Promise((resolve, reject) => { return http.get(`/api`, (err, result) => err ? reject(err) : resolve(result)) }) button.onclick = e => getUsers()
Cuối cùng, .finally()
Bên cạnh .then()
và .catch()
, chúng ta còn có .finally(onFinally)
. Phương thức này nhận vào một hàm và sẽ được kích hoạt dù cho promise trước nó hoàn thành hay xảy ra lỗi.
showLoadingSpinner() api.getUser('pikalong') .then(user => {}) .catch(err => {}) .finally(hideLoadingSpinner) // async/await async function() { try { showLoadingSpinner() api.getUser('pikalong') } catch(err) { } finally { hideLoadingSpinner() } }
Lưu ý là phương thức này hiện chỉ được hỗ trợ bởi Firefox, Chrome và Opera thôi nhé.
Trên đây, đội ngũ biên tập viên của TopDev đã cung cấp tất tần tật thông tin về Promise và cách sử dụng Promise trong JavaScript, hi vọng bài viết trên sẽ hữu ích với bạn.
Xem thêm:
Bài viết gốc được đăng tải tại 2coffee.dev
Tìm việc làm IT mới nhất trên TopDev