Giải thích về lập trình chức năng JavaScript: Kết hợp & truyền tải
Kết hợp và chuyển đổi có thể là những công cụ thiết thực nhất mà tôi đã chọn được trong thời gian học Lập trình chức năng. Chúng không phải là công cụ tôi sử dụng hàng ngày, cũng không phải là công cụ thực sự cần thiết, nhưng chúng đã thay đổi hoàn toàn cách tôi nghĩ về lập trình, tính module và tính trừu tượng trong kỹ thuật phần mềm, vĩnh viễn và tốt hơn.Và nói rõ hơn, đó thực sự là mục đích của bài viết này: Không phải để truyền bá FP, đưa ra một viên đạn bạc, hoặc làm sáng tỏ một số thứ nước sốt bí mật ma thuật “tốt hơn” so với những gì bạn làm bây giờ. Thay vào đó, vấn đề là làm sáng tỏ những cách suy nghĩ khác nhau về lập trình và mở rộng nhận thức của bạn về các giải pháp khả thi cho các vấn đề hàng ngày.
Đây không phải là những kỹ thuật dễ dàng để sử dụng thành thạo và có thể sẽ mất một thời gian, mày mò và thực hành có chủ ý để hiểu đầy đủ những gì đang xảy ra ở đây. Đối với hầu hết, đó là một cấp độ trừu tượng hoàn toàn mới.
Nhưng, nếu bạn dành thời gian, bạn có thể nhận ra cảm giác trừu tượng nhất về các chức năng mà bạn từng có.
Một ví dụ nhanh
Nhắc lại định nghĩa về hàm thuần túy là hàm không có tác dụng phụ và luôn trả về cùng một giá trị cho bất kỳ đầu vào nhất định nào.
Vì các hàm thuần túy luôn trả về cùng một giá trị cho một đầu vào nhất định, ta có thể chuyển trực tiếp các giá trị trả về của chúng trực tiếp cho các hàm khác một cách an toàn.
Điều này cho phép những điều tốt đẹp như:
// niceties colorBackground(wrapWith(makeHeading(createTitle(movie))), 'div')), 'papayawhip')
Ở đây, ta sử dụng makeHeading
để tạo một chuỗi tiêu đề ngoài movie
; sử dụng chuỗi này để tạo một tiêu đề mới (đại diện makeHeading
tới document.createElement
); bọc tiêu đề này trong một div
; và cuối cùng, gọi colorBackground
, cập nhật kiểu dáng của phần tử để cài đặt hình nền của papayawhip
… Đó là hương vị CSS yêu thích của tôi.
Hãy nói rõ về thành phần tại nơi làm việc trong đoạn mã này. Tại mỗi bước của quy trình, một hàm chấp nhận một đầu vào và trả về một kết quả , mà đầu vào xác định hoàn toàn. Chính thức hơn: Ở mỗi bước, ta thêm một hàm trong suốt có thể tham chiếu khác vào đường ống. Tuy nhiên, chính thức hơn: papayaWhipHeading
là một thành phần của các hàm trong suốt có thể tham chiếu.
Cần cho biết một con mắt chức năng cũng có thể phát hiện ra khả năng dưới đây. Nhưng bạn không ở đây để xem các ví dụ minh họa. Bạn ở đây để tìm hiểu về nhiệt hạch .
Hãy lướt qua phần còn lại của những yêu cầu đó và xem xét chuỗi các phương thức Mảng.
Chuỗi bản đồ và biểu thức bộ lọc
Một trong những tính năng đẹp hơn của map
là nó tự động trả về một mảng với các kết quả của nó.
const capitalized = ["where's", 'waldo'].map(function(word) { return word.toUpperCase(); }); console.log(capitalized); // ['WHERE'S', 'WALDO']
Tất nhiên, không có gì đặc biệt về capitalized
. Nó có tất cả các phương thức giống như bất kỳ mảng nào khác.
Vì mảng trả về map
và filter
, ta có thể xâu chuỗi các lệnh gọi đến một trong hai phương thức trực tiếp đến giá trị trả về của chúng.
const screwVowels = function(word) { return word.replace(/[aeiuo]/gi, ''); }; // Calling map on the result of calling map const capitalizedTermsWithoutVowels = ["where's", 'waldo'] .map(String.prototype.toUpperCase) .map(screwVowels);
Đây không phải là một kết quả đặc biệt ấn tượng: Các phương thức mảng chuỗi như thế này rất phổ biến trong JS-land. Tuy nhiên, nó đáng được chú ý vì nó dẫn đến mã như sau:
// Retrieve a series of 'posts' from JSON Placeholder (for fake demonstration data) // GET data fetch('https://jsonplaceholder.typicode.com/posts') // Extract POST data from response .then(data => data.json()) // This callback contains the code you should focus on--the above is boilerplate .then(data => { // filter for posts by user with userId == 1 const sluglines = data .filter(post => post.userId == 1) // Extract only post and body properties .map(post => { const extracted = { body: post.body, title: post.title }; return extracted; }) // Truncate "body" to first 17 characters, and add 3-character ellipsis .map(extracted => { extracted.body = extracted.body.substring(0, 17) + '...'; return extracted; }) // Capitalize title .map(extracted => { extracted.title = extracted.title.toUpperCase(); return extracted; }) // Create sluglines .map(extracted => { return `${extracted.title}\n${extracted.body}`; }); });
Đây có thể là một vài lệnh gọi map
nhiều hơn mức bình thường, chắc chắn… Nhưng, hãy xem xét map
cùng với filter
, và phong cách này trở nên đáng tin hơn rất nhiều.
Việc sử dụng các lệnh gọi lại "mục đích duy nhất" trong các lệnh gọi tuần tự để map
và filter
cho phép ta viết mã đơn giản hơn, với chi phí đầu vào do lệnh gọi hàm và yêu cầu đối với lệnh gọi lại "mục đích duy nhất".
Ta cũng tận hưởng những lợi ích của tính bất biến, vì map
và filter
không sửa đổi mảng mà bạn gọi chúng. Thay vào đó, họ tạo mảng mới mỗi lần.
Điều này cho phép ta tránh nhầm lẫn do các tác dụng phụ tinh vi và bảo toàn tính toàn vẹn của nguồn dữ liệu ban đầu, cho phép ta chuyển nó đến nhiều đường ống xử lý mà không gặp sự cố.
Mảng trung gian
Mặt khác, phân bổ một mảng hoàn toàn mới trên mỗi lần gọi map
hoặc filter
có vẻ hơi nặng tay.
Chuỗi lệnh gọi mà ta thực hiện ở trên có vẻ hơi “nặng tay”, bởi vì ta chỉ quan tâm đến mảng mà ta nhận được sau khi thực hiện tất cả các lệnh gọi để map
và filter
. Các mảng trung gian mà ta tạo ra trên đường đi là những mảng không đổi.
Ta tạo ra chúng với mục đích duy nhất là cung cấp cho chức năng tiếp theo trong chuỗi với dữ liệu ở định dạng mà nó mong đợi. Ta chỉ bám vào mảng cuối cùng mà ta tạo ra. Công cụ JavaScript cuối cùng sẽ thu thập các mảng trung gian mà ta đã xây dựng nhưng không cần.
Nếu bạn đang sử dụng kiểu lập trình này để xử lý các danh sách lớn, điều này có thể dẫn đến chi phí bộ nhớ đáng kể. Nói cách khác: Ta đang kinh doanh bộ nhớ và một số mã phức tạp ngẫu nhiên để có thể kiểm tra và đọc được.
Loại bỏ mảng trung gian
Để đơn giản, ta hãy xem xét một chuỗi các lệnh gọi map
.
// See bottom of snippet for `users` list users // Extract important information... .map(function (user) { // Destructuring: https://jsonplaceholder.typicode.com/users return { name, username, email, website } = user }) // Build string... .map(function (reducedUserData) { // New object only has user's name, username, email, and website // Let's reformat this data for our component const { name, username, email, website } = reduceduserdata const displayname = `${username} (${name})` const contact = `${website} (${email})` // Build the string want to drop into our UserCard component return `${displayName}\n${contact}` }) // Build components... .map(function (displayString) { return UserCardComponent(displayString) }) // Hoisting so we can keep the important part of this snippet at the top var users = [ { "id": 1, "name": "Leanne Graham", "username": "Bret", "email": "Sincere@april.biz", "address": { "street": "Kulas Light", "suite": "Apt. 556", "city": "Gwenborough", "zipcode": "92998-3874", "geo": { "lat": "-37.3159", "lng": "81.1496" } }, "phone": "1-770-736-8031 x56442", "website": "hildegard.org", "company": { "name": "Romaguera-Crona", "catchPhrase": "Multi-layered client-server neural-net", "bs": "harness real-time e-markets" } }, { "id": 2, "name": "Ervin Howell", "username": "Antonette", "email": "Shanna@melissa.tv", "address": { "street": "Victor Plains", "suite": "Suite 879", "city": "Wisokyburgh", "zipcode": "90566-7771", "geo": { "lat": "-43.9509", "lng": "-34.4618" } } } ]
Để trình bày lại vấn đề: Điều này tạo ra một mảng trung gian, "tiện ích" với mọi lệnh gọi map
. Điều này ngụ ý rằng ta không phân bổ các mảng trung gian nếu ta có thể tìm cách thực thi tất cả logic xử lý của bạn , nhưng chỉ gọi map
một lần.
Một cách để thực hiện chỉ với một lệnh gọi đến map
là thực hiện tất cả công việc của ta bên trong một lệnh gọi lại duy nhất.
const userCards = users.map(function (user) { // Destructure user we're interested in... const { name, username, email, website } = user const displayName = `${username} (${name})` const contact = `${website} (${email})` // Create display string for our component... const displayString = `${displayName}\n${contact}` // Build/return UserCard return UserCard(displayString) })
Điều này giúp loại bỏ các mảng trung gian, nhưng đây là một bước lùi. Việc ném mọi thứ vào một lệnh gọi lại sẽ làm mất đi lợi ích về khả năng đọc và khả năng kiểm tra vốn đã thúc đẩy các lệnh gọi theo trình tự để map
ngay từ đầu.
Một cách để cải thiện khả năng đọc của version này là extract các lệnh gọi lại thành các hàm của riêng chúng và sử dụng chúng trong lệnh gọi để map
, thay vì khai báo hàm theo nghĩa đen.
const extractUserData = function (user) { return { name, username, email, website } = user } const buildDisplayString = function (userData) { const { name, username, email, website } = reducedUserData const displayName = `${username} (${name})` const contact = `${website} (${email})` return `${displayName}\n${contact}` } const userCards = users.map(function (user) { const adjustedUserData = extractUserData(user) const displayString = buildDisplayString(adjustedUserData) const userCard = UserCardComponent(displayString) return userCard })
Điều này về mặt logic tương đương với những gì ta đã bắt đầu, do tính minh bạch có thể tham chiếu. Nhưng, nó chắc chắn dễ đọc hơn và dễ kiểm tra hơn.
Chiến thắng thực sự ở đây là version này làm cho cấu trúc logic xử lý của ta rõ ràng hơn nhiều: nghe giống như thành phần chức năng, phải không?
Ta có thể tiến thêm một bước nữa. Thay vì lưu kết quả của mỗi lệnh gọi hàm vào một biến, ta có thể chỉ cần chuyển trực tiếp kết quả của mỗi lệnh gọi đến hàm tiếp theo trong chuỗi.
const userCards = users.map(function (user) { const userCard = UserCardComponent(buildDisplayString(extractUserData(user))) return userCard })
Hoặc, nếu bạn thích mã ngắn gọn hơn:
const userCards = users.map(user => UserCardComponent(buildDisplayString(extractUserData(user))))
Thành phần và kết hợp
Điều này khôi phục tất cả khả năng kiểm tra và một số khả năng đọc được của chuỗi lệnh gọi map
ban đầu của ta . Và vì ta đã quản lý để thể hiện sự biến đổi này chỉ với một lệnh gọi map
duy nhất, ta đã loại bỏ chi phí bộ nhớ do các mảng trung gian áp đặt.
Ta đã thực hiện điều này bằng cách chuyển đổi chuỗi các cuộc gọi của ta để map
, mỗi cuộc gọi nhận được một lệnh gọi lại "mục đích duy nhất", thành một lệnh gọi duy nhất để map
, trong đó ta sử dụng một thành phần của các lệnh gọi lại đó.
Quá trình này được gọi là hợp nhất , và cho phép ta tránh được phí tổn của các mảng trung gian trong khi tận hưởng các lợi ích về khả năng kiểm tra và khả năng đọc của các lệnh gọi theo trình tự tới map
.
Một cải tiến cuối cùng. Hãy lấy gợi ý từ Python và rõ ràng về những gì ta đang làm.
const R = require('ramda'); // Use composition to use "single-purpose" callbacks to define a single transformation function const buildUsercard = R.compose(UserCardComponent, buildDisplayString, extractUserData) // Generate our list of user components const userCards = users.map(buildUserCard)
Ta có thể viết một trình trợ giúp để làm cho việc này trở nên sạch sẽ hơn.
const R = require('ramda') const fuse = (list, functions) => list.map(R.compose(...functions)) // Then... const userCards = fuse( // list to transform users, // functions to apply [UserCardComponent, buildDisplayString, extractUserData] )
Tan chảy
Nếu bạn giống tôi, đây là phần mà bạn bắt đầu sử dụng map
và filter
ở mọi nơi, ngay cả những thứ mà bạn có thể không nên sử dụng nó.
Nhưng mức cao không tồn tại lâu với cái này. Kiểm tra điều này:
users // Today, I've decided I hate the letter a .filter(function (user) { return user.name[0].toLowerCase() == 'a' }) .map(function (user) { const { name, email } = user return `${name}'s email address is: ${email}.` })
Fusion hoạt động tốt với một chuỗi các lệnh gọi map
. Nó cũng hoạt động với một chuỗi các lệnh gọi để filter
. Thật không may, nó bị hỏng với các cuộc gọi tuần tự liên quan đến cả hai phương thức. Fusion chỉ hoạt động đối với các lệnh gọi theo trình tự tới một trong các phương thức này.
Đó là bởi vì họ diễn giải các giá trị trả về của các lệnh gọi lại của họ khác nhau. map
nhận giá trị trả về và đẩy nó vào một mảng, dù nó là gì.
filter
, filter
giải thích tính xác thực của giá trị trả về của lệnh gọi lại. Nếu callback trả về true
cho một phần tử, nó sẽ giữ phần tử đó. Nếu không, nó sẽ ném nó ra ngoài.
Fusion không hoạt động vì không có cách nào để nói cho hàm hợp nhất biết những lệnh gọi lại nào nên được sử dụng làm bộ lọc và lệnh nào nên được sử dụng như các phép biến đổi đơn giản.
Nói cách khác: Cách tiếp cận hợp nhất này chỉ hoạt động trong trường hợp đặc biệt của chuỗi lệnh gọi map
và filter
.
Truyền tải
Như ta đã thấy, kết hợp chỉ hoạt động cho một loạt các lệnh gọi chỉ liên quan đến bản đồ hoặc chỉ liên quan đến bộ lọc. Điều này không hữu ích lắm trong thực tế, nơi ta thường gọi cả hai. Nhớ lại rằng ta đã có thể thể hiện map
và filter
theo cách reduce
.
// Expressing `map` in terms of `reduce` const map = (list, mapFunction) => { const output = list.reduce((transformedList, nextElement) => { // use the mapFunction to transform the nextElement in the list const transformedElement = mapFunction(nextElement); // add transformedElement to our list of transformed elements transformedList.push(transformedElement); // return list of transformed elements return transformedList; }, []) // ^ start with an empty list return output; } // Expressing `filter` in terms of `reduce` const filter = (list, predicate) => { const output = list.reduce(function (filteredElements, nextElement) { // only add `nextElement` if it passes our test if (predicate(nextElement)) { filteredElements.push(nextElement); } // return the list of filtered elements on each iteration return filteredElements; }, []) }) }
Về lý thuyết, điều này nghĩa là ta có thể thay thế các lệnh gọi của bạn để map
và sau đó filter
bằng các lệnh gọi để reduce
. Sau đó, ta sẽ có một chuỗi các cuộc gọi chỉ liên quan đến reduce
, nhưng thực hiện cùng một logic ánh xạ / lọc mà ta đã sử dụng.
Từ đó, ta có thể áp dụng một kỹ thuật rất giống với những gì ta đã thấy với sự hợp nhất để thể hiện chuỗi giảm của ta về mặt thành phần hàm duy nhất.
Bước 1: mapReducer và filterReducer
Bước đầu tiên là diễn đạt lại các lời kêu gọi của ta để map
và filter
theo cách reduce
.
Trước đây, ta đã viết các version bản map
và filter
của riêng mình, trông giống như sau:
const mapReducer = (list, mapFunction) => { const output = list.reduce((transformedList, nextElement) => { // use the mapFunction to transform the nextElement in the list const transformedElement = mapFunction(nextElement); // add transformedElement to our list of transformed elements transformedList.push(transformedElement); // return list of transformed elements return transformedList; }, []) // ^ start with an empty list return output; } const filterReducer = (list, predicate) => { const output = list.reduce(function (filteredElements, nextElement) { // only add `nextElement` if it passes our test if (predicate(nextElement)) { filteredElements.push(nextElement); } // return the list of filtered elements on each iteration return filteredElements; }, []) }) }
Ta đã sử dụng chúng để chứng minh mối quan hệ giữa reduce
và map
/ filter
, nhưng ta cần thực hiện một số thay đổi nếu ta muốn sử dụng điều này trong chuỗi reduce
.
Hãy bắt đầu bằng cách xóa các cuộc gọi đó để reduce
:
const mapReducer = mapFunction => (transformedList, nextElement) => { const transformedElement = mapFunction(nextElement); transformedList.push(transformedElement); return transformedList; } const filterReducer = predicate => (filteredElements, nextElement) => { if (predicate(nextElement)) { filteredElements.push(nextElement); } return filteredElements; }
Trước đó, ta đã lọc và ánh xạ một loạt tên user
. Hãy bắt đầu viết lại logic đó với các hàm mới này để làm cho tất cả những điều này bớt trừu tượng hơn một chút.
// filter's predicate function function removeNamesStartingWithA (user) { return user.name[0].toLowerCase() != 'a' } // map's transformation function function createUserInfoString (user) { const { name, email } = user return `${name}'s email address is: ${email}.` } users .reduce(filterReducer(removeNamesStartingWithA), []) .reduce(mapReducer(createUserInfoString), [])
Điều này tạo ra kết quả giống như chuỗi filter
/ map
trước đó của ta .
Đây là một vài lớp liên quan đến chuyển hướng. Hãy dành một chút thời gian để xem qua đoạn mã trên trước khi tiếp tục.
Bước 2: Tổng quát hóa chức năng gấp của ta
Hãy xem mapReducer
và filterReducer
.
const mapReducer = mapFunction => (transformedList, nextElement) => { const transformedElement = mapFunction(nextElement); transformedList.push(transformedElement); return transformedList; } const filterReducer = predicate => (filteredElements, nextElement) => { if (predicate(nextElement)) { filteredElements.push(nextElement); } return filteredElements; }
Thay vì chuyển đổi mã cứng hoặc logic vị từ, ta cho phép user chuyển các hàm ánh xạ và vị từ dưới dạng đối số mà các ứng dụng một phần của mapReducer
và filterReducer
ghi nhớ do quá trình đóng.
Bằng cách này, ta có thể sử dụng mapReducer
và filterReducer
làm "xương sống" trong việc xây dựng chuỗi giảm tùy ý bằng cách chuyển predicate
hoặc mapFunction
phù hợp với trường hợp sử dụng của ta .
Nếu bạn quan sát kỹ, bạn sẽ nhận thấy rằng ta vẫn thực hiện các lệnh gọi rõ ràng để push
vào cả hai bộ giảm này. Điều này rất quan trọng, bởi vì push
là chức năng cho phép ta kết hợp hoặc giảm bớt hai đối tượng thành một:
// Object 1... const accumulator = ["an old element"]; // Object 2... const next_element = "a new element"; // A single object that combines both! Eureka! accumulator.push(next_element); // ["an old element", "a new element"] console.log(accumulator)
Nhớ lại rằng kết hợp các phần tử như thế này là toàn bộ điểm của việc sử dụng reduce
ngay từ đầu.
Nếu bạn nghĩ về nó, push
không phải là chức năng duy nhất ta có thể sử dụng để làm điều này. Thay vào đó, ta có thể sử dụng unshift
:
// Object 1... const accumulator = ["an old element"]; // Object 2... const next_element = "a new element"; // A single object that combines both! Eureka! accumulator.unshift(next_element); // ["a new element", "an old element"] console.log(accumulator);
Như đã viết, bộ giảm tốc của ta khóa ta sử dụng push
. Nếu ta muốn unshift
, thay vào đó, ta sẽ phải tái thực hiện mapReducer
và filterReducer
.
Giải pháp là sự trừu tượng. Thay vì push
mã cứng, ta sẽ cho phép user chuyển hàm họ muốn sử dụng để kết hợp các phần tử làm đối số.
const mapReducer = combiner => mapFunction => (transformedList, nextElement) => { const transformedElement = mapFunction(nextElement); transformedList = combiner(transformedList, transformedElement); return transformedList; } const filterReducer = combiner => predicate => (filteredElements, nextElement) => { if (predicate(nextElement)) { filteredElements = combiner(filteredElements, nextElement); } return filteredElements; }
Ta sử dụng nó như thế này:
// push element to list, and return updated list const pushCombiner = (list, element) => { list.push(element); return list; } const mapReducer = mapFunction => combiner => (transformedList, nextElement) => { const transformedElement = mapFunction(nextElement); transformedList = combiner(transformedList, transformedElement); return transformedList; } const filterReducer = predicate => combiner => (filteredElements, nextElement) => { if (predicate(nextElement)) { filteredElements = combiner(filteredElements, nextElement); } return filteredElements; } users .reduce( filterReducer(removeNamesStartingWithA)(pushCombiner), []) .reduce( mapReducer(createUserInfoString)(pushCombiner), [])
Bước 3: Truyền tải
Đến đây, mọi thứ đã sẵn sàng cho mẹo cuối cùng của ta : Soạn các phép biến đổi này để hợp nhất các lệnh gọi chuỗi đó để reduce
. Hãy xem nó hoạt động trước, và sau đó đánh giá.
const R = require('ramda'); // final mapReducer/filterReducer functions const mapReducer = mapFunction => combiner => (transformedList, nextElement) => { const transformedElement = mapFunction(nextElement); transformedList = combiner(transformedList, transformedElement); return transformedList; } const filterReducer = predicate => combiner => (filteredElements, nextElement) => { if (predicate(nextElement)) { filteredElements = combiner(filteredElements, nextElement); } return filteredElements; } // push element to list, and return updated list const pushCombiner = (list, element) => { list.push(element); return list; } // filter's predicate function const removeNamesStartingWithA = user => { return user.name[0].toLowerCase() != 'a' } // map's transformation function const createUserInfoString = user => { const { name, email } = user return `${name}'s email address is: ${email}.` } // use composition to create a chain of functions for fusion (!) const reductionChain = R.compose( filterReducer(removeNamesStartingWithA) mapReducer(createUserInfoString), ) users .reduce(reductionChain(pushCombiner), [])
Ta có thể tiến xa hơn bằng cách triển khai chức năng trợ giúp.
const transduce = (input, initialAccumulator, combiner, reducers) => { const reductionChain = R.compose(...reducers); return input.reduce(reductionChain(combiner), initialAccumulator) } const result = transduce(users, [], pushCombiner, [ filterReducer(removeNamesStartingWithA) mapReducer(createUserInfoString), ]);
Kết luận
Có nhiều giải pháp cho hầu hết mọi vấn đề hơn bất kỳ ai có thể liệt kê; bạn gặp càng nhiều người trong số họ, bạn càng nghĩ rõ ràng hơn về chính mình và bạn sẽ thấy vui hơn khi làm như vậy.
Tôi hy vọng việc gặp gỡ Fusion và Transduction sẽ khơi dậy sự quan tâm của bạn, giúp bạn suy nghĩ rõ ràng hơn và, đầy tham vọng như hiện tại, ít nhất là một chút vui vẻ.
Các tin liên quan
Cách gói một gói JavaScript Vanilla để sử dụng trong React2019-12-12
Cách phát triển một trình tải lên tệp tương tác với JavaScript và Canvas
2019-12-12
Cách sử dụng map (), filter () và Reduce () trong JavaScript
2019-12-12
Giải thích về lập trình chức năng JavaScript: Ứng dụng một phần và làm xoăn
2019-12-12
Giới thiệu về Closures và Currying trong JavaScript
2019-12-12
Bắt đầu với các hàm mũi tên ES6 trong JavaScript
2019-12-12
Cách đếm số nguyên âm trong một chuỗi văn bản bằng thuật toán JavaScript
2019-12-12
Cách sử dụng phép gán cấu trúc hủy trong JavaScript
2019-12-12
Cách sử dụng .every () và .some () để điều khiển mảng JavaScript
2019-12-12
Chuyển đổi Mảng sang Chuỗi trong JavaScript
2019-12-05