JavaScript Array có gì khác với các dạng Object khác?
Array là một dạng cấu trúc dữ liệu mà chúng ta thường xuyên bắt gặp trong bất kỳ một ngôn ngữ nào. Trong JavaScript, array là nó được coi là kiểu Object, nhưng được engine tối ưu để tăng hiệu suất, trong nội dung này chúng ta sẽ tìm hiểu thêm một chút về nó và các ứng dụng trong thực tế.
Lần trước mình có viết về Array Cấu trúc dữ liệu với JS — phần 1: Array , tuy nhiên, mình thấy có một số nội dung cần được cập nhật lại để tiếp cận thực tế hơn.
Array là một dạng cấu trúc dữ liệu mà chúng ta thường xuyên bắt gặp trong bất kỳ một ngôn ngữ nào nên việc phải xử lý dữ liệu dạng array là chuyện “cơm bữa”. Tuy nhiên, lúc mới bắt đầu, chúng ta cũng hay lẫn lộn khi phải xác định giá trị của phần tử (element) và chỉ mục (index) của nó. Khi dùng nhiều rồi, bạn lại còn gặp tình trạng lúc nào hầu như cũng phải có 1 hàm tiện ích nho nhỏ để tái sử dụng cho array, ví dụ như tiện ích tìm một object trong array, so sánh 2 array, hay là tìm vị trí của object đó... Nói chung, bản thân mình thấy, cũng một vấn đề, cơ bản đã học và dùng nhiều, quay lại vẫn có cái để biết thêm.
Cấu trúc của Array
Trong JavaScript, một array bao gồm các thành phần:
- Phần tử: Mỗi thành phần hay giá trị được lưu trong array được gọi là phần tử. Ở trong hình minh họa, giá trị các phần tử lần lượt từ e1 đến e10.
- Chỉ mục: Xác định vị trí của một phần tử, trong JS, chỉ mục được bắt đầu từ số 0 (như nhiều ngôn ngữ khác).
- Kích cỡ của array: Kích cỡ này thường được hiểu là số lượng thành phần trong một array. Tuy nhiên, việc đếm nó không dựa trên số lượng thành phần hiện có trong array mà là số cuối cùng của thành phần trong array, ta có thể vô tình hay hữu ý thay đổi kích thước của array nên, một lát mình sẽ ví dụ rõ hơn.
Array một chiều (One dimension Array)
Array một chiều là dạng cơ bản nhất của array, nó là array tuyến tính, nó còn được gọi là vector. Trong JavaScript, ta có thể khai báo Array bằng nhiều cách.
const arr = ['foo', 'bar']
// hay là
const arr = new Array(element0 , element1 [, ... [, elementN ]])
Trên thực tế, việc khai báo new Array hiếm gặp, vì dùng [] nhanh hơn, vả lại, nếu khai báo bằng một giá trị là số thì nó sẽ tạo một array rỗng có độ rộng bằng số đó.
const arr = new Array(2)
console.log(arr) // tạo ra array rỗng
console.log(arr.length) // 2
Array đa chiều (Muti-dimensional Array)
Array đa chiều là array chứa trong nó array con. Bản thân JavaScript không cung cấp array đa chiều, cách đơn giản để khai báo một array là sử dụng literal notation (không biết tiếng Việt viết sao cho chuẩn).
ví dụ
const activities = [
['Work', 9],
['Eat', 2],
['Commute', 2],
['Play Game', 2],
['Sleep', 7]
]
Jagged Array
Jagged array là array đa chiều với các array con không đồng nhất về kích cỡ. VD:
const arr = [
["Matthew", "27", "Developer"],
["Simon", "24"],
["Luke"]
]
Array hỗn hợp
Trường này ít khi sử dụng thực tế, tuy nhiên vẫn có thể xảy ra.
const arr = [ 'Apple', { name: 'John' }, true, function() { alert('hello'); } ]
Array là một dạng đối tượng (object) đặc biệt
Trong JS, array là một kiểu đối tượng (object type) đặc biệt. Việc truy vấn dữ liệu sử dụng ngoặc vuông [] thực chất là cách truy vấn dành cho object, khi ta dùng arr[0] cũng tương tự như gọi object[key] , về kiểu của dữ liệu mình cũng đã nhắc đến trong nội dung Hiểu đúng về tham chiếu JavaScript
Lý do tồn tại array vì nó khác biệt ở chỗ là engine sẽ lưu trữ nó ở vùng nhớ có tính nối tiếp, từng phần tử tiếp nối nhau, engine (ví dụ như v8) sẽ tối ưu nó giúp cho array có tốc độ truy cập rất nhanh. Bạn nào quan tâm thì có thể xem thêm ở đây Elements kinds in V8 · V8
Khái niệm length trong array
Nhắc lại khái niệm array.length đã đề cập ở đoạn mở đầu, length là một thuộc tính được tính bằng số index lớn nhất +1, nó sẽ cập nhật khi chúng ta thay đổi nội dung array. Việc thay đổi kích cỡ array tức là bổ sung, gộp, hoặc loại bỏ phần tử bên trong array. Ví dụ:
let arr = [1, 2, 3, 4, 5];
console.log(arr.length); // 5
arr.length--;
console.log(arr.length); // 4
arr.length += 15;
console.log(arr.length); // 19
Điều thú vị là length là một giá trị có thể “ghi” được. Khi ta giảm giá trị length thì array bị “xén” đi.
let arr = [1, 2, 3, 4, 5];
arr.length = 2;
console.log(arr) // [1,2]
Nên nếu muốn làm rỗng array thì chỉ cần đặt length = 0
let array = [1, 2, 3, 4, 5, 6];
array.length = 0;
Đảm bảo không còn một mống trong vòng một nốt nhạc… à không, một nano nốt thì đúng hơn 😆
Việc “xén” array kiểu này có hiệu suất cao vì nó làm thay đổi kích cỡ array mà không ảnh hưởng đến cấu trúc mang tính trình tự của nó.
Phá vỡ cấu trúc array
Do nó vẫn là một dạng object nên việc thay đổi kích thước array tùy tiện vẫn có khả năng tạo bug, bởi vì cấu trúc sắp xếp của nó đã bị phá vỡ. Xem ví dụ sau:
let arr = [];
console.log(arr.length); // 0
arr[100] = 'some value';
console.log(arr.length); // có kích cỡ 101 nhưng chỉ có một phần tử
Engine vẫn nhìn nhận nó như là một object thông thường, và việc tối ưu cụ thể cho array không còn nữa.
Vì vậy một số trường hợp có thể làm vỡ cấu trúc array như là:
- Thêm một thuộc tính không phải số nguyên arr.foo = 5.
- Tạo một khoảng trống như là tạo arr[0] sau đó thêm arr[1000] (giữa nó chả có gì).
- Thay đổi kích thước array theo kiểu đảo ngược trình tự, ví dụ tạo arr[1000] trước, rồi lại tạo arr[999] sau đó.
Vậy, để tối ưu sử dụng array, chúng ta luôn phải “cư xử” nó như một dữ liệu có cấu trúc sắp xếp theo trình tự. Bất kỳ việc phá vỡ cấu trúc nào của array, sẽ ảnh hưởng đến tính tối ưu của nó. Engine chỉ coi nó như là một object thông thường.
So sánh array
Array là một object type, vì vậy chúng ta không thể so sánh === như primitive type được, vì việc so sánh đó sẽ kết thúc bằng kết quả không hợp lý.
Cách đơn giản nhất mà mình hay áp dụng khi so sánh array là dùng JSON.stringify(arrayA) === JSON.stringify(arrayB)
Tuy nhiên, vẫn có trường hợp xảy ra là 2 array được so sánh đó có phần tử giống nhau, chỉ khác nhau về mặt trình tự index thì phương thức so sánh trên sẽ không còn hợp lý nữa. Ví dụ:
const arrA = ["a", "b", "c", "d"]
const arrB = ["c", "d", "a", "b"]
Lúc này, để so sánh thì chúng ta cần nhiều bước hơn để so sánh, ví dụ
function compareArrays(a,b) {
if(a.length !== b.length) return false
const result = a.every((el) => el == b.includes(el))
return result
}
Các phương thức array
Một array prototype có khá nhiều phương thức dựng sẵn (built-in methods). Bạn có thể xem toàn bộ danh sách ở đây Array - JavaScript | MDN
Chúng ta sẽ xem xét một vài phương thức được dùng khá nhiều trong thực tế.
Chuyển array thành string
Để chuyển một array thành string ta có nhiều cách
["hello", "world", "foo", "bar"].toString() // "hello,world,foo,bar"
String(["hello", "world", "foo", "bar"]) // "hello,world,foo,bar"
Trong một số trường hợp, chúng ta dùng join có thể chèn thêm ký tự khi chuyển thành string
// thêm dấu phẩy và khoảng trống giữa các từ
["these", "are", "keywords"].join(", ") // "these, are, keywords"
Thay đổi kích thước array
Các phương thức mà chúng ta hay dùng để thay đổi một array
Thêm bớt phần tử
- push thêm phần tử vào cuối array.
- pop xóa phần tử ở cuối.
- unshift thêm phần tử vào đầu array.
- shift xóa phần tử ở đầu array.
- splice xóa các phần tử liên tiếp nhau sau vị trí chỉ mục.
shift/unshift và push/pop
Về hiệu suất, shift/unshift chậm hơn push/pop, chúng ta có thể lý giải như sau:
Với shift , khi code phải làm 3 việc:
- Xóa phần tử ở index 0
- Đưa tất cả các phần tử lên trước và thực hiện index lại vị trí, ví dụ như 1 index lại là 0, 2 index lại là 1 … lần lượt như vậy
- Cập nhật length
Tương tự, với unshift cách thực hiện cũng giống vậy theo cách ngược lại. Array càng chứa nhiều phần tử, càng mất thời gian để thực hiện công việc như trên.
Với pop hay push thì không phải thực hiện thay đổi vị trí như vậy, array chỉ bị cắt bớt hoặc thêm đuôi mà thôi.
Splice
Array prototype có một phương thức splice khá thuận tiện dùng để thêm, bớt hay thay thế phần tử ở một ví trí index xác định bên trong array.
Cú pháp
splice(startIndex, deleteCount, element1, element2,...,elementN)
Trong đó startIndex là vị trí index bắt đầu, deleteCount là số phần tử sẽ bị xóa tính từ startIndex ,
element1, element2,...,elementN là các phần tử được “lấp” vào sau khi xóa.
Nếu startIndex là một số âm, thì vị trí bắt đầu sẽ đếm ngược từ vị trí index cuối cùng, do đó, nếu startIndex = -n tương tự với array.length - 1 nếu startIndex nhỏ hơn độ rộng của array thì startIndex sẽ = 0.
Ví dụ
const arr = ["a", "b", "c", "d"]
// Xóa 2 phần tử bắt đầu từ index 0
arr.splice(0, 2) ["a", "b"]
console.log(arr) // ["c", "d"]
// Thêm phần tử ở vị trí index 2
arr.splice(2, 0, "foo")
console.log(arr) // ["a", "b", "foo", "c", "d"]
Đây là một phương thức tốn công sức ECMAScript® 2022 Language Specification, vì khối lượng công việc mà nó phải thực. Tuy nhiên với nhiều trường hợp, bạn muốn dùng arr.splice(-1) để lấy phần tử cuối thay vì arr[arr.length -1] thì cũng chả ảnh hưởng gì.
Slice
Một hàm khác mà mọi người cũng hay nhầm lẫn với splice là slice , tuy nhiên, thay vì thêm sửa xóa phần tử trong array, mục đích của slice là “cắt” một phần của array. Gọi là “cắt” nhưng thực ra nó là một bản sao nông (shallow copy) của đoạn array chúng ta đã chọn.
Cú pháp
slice(startIndex, endIndex)
Trong đó startIndex là vị trí bắt đầu và endIndex là vị trí kết thúc. Tuy nhiên, kết quả trả về sẽ bao gồm phần tử ở vị trí startIndex và không bao gồm phần tử ở vị trí endIndex
Việc tính vị trí startIndex cũng tương tự với phương thức splice trong trường hợp startIndex là số âm. Và lưu ý là nếu startIndex lớn hơn độ rộng của array thì kết quả sẽ bằng một array rỗng.
Ví dụ
const arr = ['a', 'b', 'c', 'd', 'e'];
// Copy các phần tử bắt đầu từ index 2
console.log(animals.slice(2)) // ["c", "d", "e"]
// Copy các phần tử bắt đầu từ index 2 đến index 4
console.log(animals.slice(2, 4)); //["c", "d"]
// Copy các phần từ index cuối cùng trở về trước 2 bước
console.log(animals.slice(-2)); // ["d", "e"]
// Copy các phần tử từ index 2
console.log(animals.slice(2, -1)); // ["c", "d"]
Gộp array
Với ES6, chúng ta thường sử dụng spread operator để gộp các array lại với nhau thay vì sử dụng phương thức có sẵn của Array vì cách này nhanh, tiện dễ đọc.
const a = [1, 2, 3];
const b = [4, 5, 6];
const merged = [...a, ...b]; // [1, 2, 3, 4, 5, 6]
Về mặt hiệu suất, spread operator có hiệu suất tốt hơn concat. Tuy nhiên, phương thức này không áp dụng trong trường hợp giá trị nó là một giá trị non-array, ví dụ như const c = true thì spread operator sẽ báo lỗi là c is not iterable , lúc này ta cần dùng concat
const a = [1, 2, 3];
const b = [4, 5, 6];
const c = true
console.log(a.concat(b, c)) // [1, 2, 3, 4, 5, 6, true]
Tuy nhiên, hàm Array.concat() đối với các array lớn sẽ tiêu thụ rất nhiều bộ nhớ khi tạo từng các array mới. Vì vậy khi sử dụng với mảng có kích cỡ lớn, tốt nhất là nên sử dụng push thay vì concat.
array1.push.apply(array1, array2)
// Hoặc
Array.push(...arr1, ...arr2)
Vòng lặp
Vòng lặp được coi như là một phương thức gắn liền với array, “ở đâu có array, ở đó có vòng lặp”. Trong nội dung này, mình sẽ không đề cập đến các cách thực hiện lặp như là for, for...in for...of, hay while … mà là các phương thức built-in của array prototype chúng ta thường dùng trong thực tế.
forEach
forEach sẽ lặp tuần tự trên từng thành phần, và thực thi một hàm callback có các tham số được định sẵn trên từng thành phần đó. forEach được sử dụng với cú pháp sau:
Array.forEach(function callbackFn(element, index, array) { ... }, thisArg)
Ngoài việc dễ đọc, dễ sử dụng, có một số đặc điểm lưu ý khi chúng ta sử dụng forEach
- Ta có thể xử lý được dữ liệu trên phần tử mà không phải kiểm tra index, do đó việc debug cũng khá là thuận tiện.
- forEach chỉ kết thúc khi nó lặp hết toàn bộ các phần tử trong array, vì vậy nếu dùng nó cho một mảng lớn thì sẽ cần thời gian để hoàn thành. Hoặc nếu bạn cần có điều kiện dừng thì nên dùng những phương thức khác như find, findIndex , some, every, hoặc là vòng lặp for
- Các hàm chạy bên trong vòng lặp forEach chỉ xử lý đồng bộ, do đó việc sử dụng bất đồng bộ (async/promise) bên trong forEach là không thể.
// Không nên sử dụng như thế này
myArray.forEach(async function(item) {
await asyncTodoSomething(item)
})
find, findIndex
Hai phương thức này sẽ lặp và dừng lại khi gặp được giá trị thỏa điều kiện.
find tìm và trả về giá trị của phần tử
const arr = [4, 8, 40, 130, 44];
console.log(arr.find(item => item > 40)) // Kết quả 130 vì nó là phần tử đầu tiên thỏa điều kiện
findIndex tìm và trả về vị trí của phần tử
const arr = [4, 8, 40, 130, 44];
console.log(arr.findInde(item => item > 40)) // Kết quả 3 vì nó là index của vị trí phần tử đầu tiên thỏa điều kiện
Chúng ta hay kết hợp 2 phương thức này để tìm một đối tượng bên trong array, ví dụ:
const artists = [
{ name: "Greg Howe", hits: 30 },
{ name: "Guthrie Govan", hits: 34 },
{ name: "Shawn Lane", hits: 25 },
{ name: "Jimmy Hendrix", hits: 50 }
];
// Tìm nghệ sĩ có tên "Govan"
const artist = artists.find(function (artist) {
return artist.name.indexOf('Govan') > -1;
});
console.log(artist)
some, every, includes
Các phương thức này giống như là các tiện ích và được sử dụng với mục đích kiểm tra mảng, và trả về giá trị boolean. Các phương thức này tiện dụng vì nó có thể kiểm tra phần tử mà không cần phải quan tâm đến vị trí của phần tử.
every kiểm tra toàn bộ các phần tử trong array thỏa mãn điều kiện hay không.
const cart = [
{
item: 'apples',
price: 4
},
{
item: 'pears',
price: 5
},
{
item: 'bread',
price: 3
}
];
// Kiểm tra các món hàng trong giỏ hàng đều lớn hơn 2
const isMeetDiscountMargin = cart.every(item => item.price >2)
// giảm giá 20%
const discount = isMeetDiscountMargin ? 0.2 : 0
includes kiểm một phần tử xác định có tồn tại trong array hay không.
const arr = ["foo", "bar", "baz"]
if(arr.includes("bar")) {
// Thực thi gì đó bên trong
}
some kiểm tra có ít nhất một phần tử trong array thỏa mãn điều kiện. Khác với includes , tham số của some là hàm callback, vì vậy bạn có thể thiết lập logic bên trong nó.
const cart = [
{
item: 'apples',
price: 1
},
{
item: 'pears',
price: 5
},
{
item: 'bread',
price: 3
}
];
// Kiểm tra nếu trong giỏ hàng có lê
const hasPears = cart.some(item => item.item === 'pears');
// giảm giá 20%
const discount = hasPears ? 0.2 : 0;
map, filter, reduce
Map, filter, reduce là bộ ba “thần thánh” được sử dụng khá nhiều trong thực tế.
map
Nếu các bạn đã sử dụng React thì không xa lạ gì với map vì đây là phương thức dùng để render một danh sách. Mục đích của map chủ yếu dùng để “chuyển hóa” một array vì nó trả về một array mới từ việc sử dụng những thuộc tính của array cũ cho array mới.
const cart = [
{
name: 'apples',
price: 1
},
{
name: 'pears',
price: 5
},
{
name: 'bread',
price: 3
}
];
const uppercaseItems = cart.map(item => item.name.toUpperCase())
console.log(uppercaseItems) //
[{name: 'APPLES',price: 1},{name: 'PEARS',price: 5},{name: 'BREAD',price: 3}];
filter
Phương thức filter rất hữu dụng trong trường hợp bạn cần loại bỏ thành phần bên trong array
const items = [
{
name: 'apples',
price: 1,
inStock: false
},
{
name: 'pears',
price: 5,
inStock: true
},
{
name: 'bread',
price: 3,
inStock: true
}
];
const inStockItems = items.filter(i => i.inStock)
console.log(inStockItems) // [{name: 'pears',price: 5,inStock: true},{name: 'bread',price: 3,inStock: true}]
Sự thuận tiện của hàm này là bạn có thể tùy ý đặt điều kiện thỏa mãn của phần tử. Ví dụ như
items.filter(i => i.inStock && i.price>3)
Phương thức filter còn cho phép chúng ta lọc bỏ những phần tử không có giá trị “hợp lệ” như null, ““, undefined, {}, NaN
let schema = ["hello", "world", null, "", undefined,{}, NaN, "goodbye"]
schema = schema.filter(n => n);
console.log(schema) // ["hello", "world", "goodbye"]
reduce
Với phương thức reduce thì cũng có nhiều người cảm thấy bỡ ngỡ khi mới làm quen với nó, nên mình sẽ giải thích một chút về các tham số của hàm reduce . Cú pháp đầy đủ của hàm này là
function callbackFn(previousValue, currentValue, currentIndex, array) { ... }
reduce(callbackFn, initialValue)
callbackFn
Để hiểu reduce, trước tiên chúng ta phải hiểu hàm callbackFn, tương tự như các phương thức lặp của array khác, nó là hàm call back được gọi lần lượt trên từng phần tử của array, nó bao gồm các tham số
- previousValue giá trị cuối gần nhất được trả về bởi hàm callbackFn .
- currentValue là giá trị của phần tử hiện tại
- currentIndex là vị trí index
- array là toàn bộ array gốc, giá trị này ít khi dùng đến.
const arr = [1, 2, 3, 4];
const reducer = (previousValue, currentValue) => previousValue + currentValue;
// 1 + 2 + 3 + 4
console.log(arr.reduce(reducer)); // 10
initialValue là giá trị khởi tạo ngay trước vòng lặp đầu tiên thực hiện. Nếu giá trị này được khai báo, thì tại vòng lặp đầu tiên, nó là giá trị khởi tạo được đại diện cho previousValue , currentIndex sẽ bắt đầu từ 0. Ngược lại, nếu không khai báo thì currentIndex sẽ bắt đầu từ vị trí 1 , xem ví dụ sau:
const array1 = [1, 2, 3, 4];
const reducer = (previousValue, currentValue, currentIndex) => {
console.log(currentIndex, previousValue)
return previousValue + currentValue
};
const arr = [1, 2, 3, 4];
// tương đương 1 + 2 + 3 + 4
arr.reduce(reducer)
// giá trị in ra trên console là
// > 1 1
// > 2 3
// > 3 6
// Thêm giá trị khởi tạo
arr.reduce(reducer, 2)
// giá trị in ra trên console là
// > 0 2
// > 1 3
// > 2 5
// > 3 8
Giá trị khởi tạo có lợi ích khi bạn áp dụng cho một array chứa phần tử là các đối tượng. Ví dụ như tính tổng giá trị các item bên trong array như sau:
const items = [
{
name: 'apples',
price: 1,
inStock: false
},
{
name: 'pears',
price: 5,
inStock: true
},
{
name: 'bread',
price: 3,
inStock: true
}
];
const reducer = (accumulator, currentItem) => accumulator + currentItem.price
const totalPrice = items.reduce(reducer, 0)
console.log(totalPrice) // 9
Bộ 3 map, filter, reduce này còn có một “uy lực” nữa là sự phối hợp chúng lại với nhau.
Ví dụ như chúng ta chỉ muốn lấy tên của các sản phẩm có giá hơn 1 đồng trở lên, có thể phối hợp filter với map
// dùng danh sách items ở trên
items.filter(item => item.price > 1).map(item => item.name)
// output: ["pears", "bread"]
hay là tính tổng giá của các sản phẩm có giá trên 1 đồng, ta có thể phối hợp filter với reduce
items.filter(item => item.price > 1).reduce(((acc, item) => acc + item.price), 0)
// output: 8
Ok, trường hợp phức tạp hơn xíu, cũng là tính tổng giá cho các sản phẩm có giá trên 1 đồng giống ví trụ trên, nhưng bạn muốn giảm 2 đồng cho sản phẩm có giá từ 5 đồng trở lên. Ta dùng luôn cả 3 hàm map, filter, reduce
items
.map(item => {
if(item.price >= 5) {
item.price -= 2
}
return item
})
.filter(item => item.price > 1)
.reduce(((acc, item) => acc + item.price), 0)
// output: 6
Đơn giản như ăn nhãn đúng không?
Lời Kết
Với array, vẫn còn nhiều phương thức hay ngoài các phương thức đã đề cập trung trong nội dung này, tuy nhiên mình cũng tạm dừng ở đây, vì nếu nhiều quá mà không dùng đến cũng tẩu hỏa, tốt nhất thì ứng dụng quan trọng vẫn là thực tế chứ không phải nhiều hay ít. Bản thân Array là một dạng cấu trúc dữ liệu cơ bản, nhưng nó cũng là nền tảng đóng vai trò rất quan trọng đối với nhiều dạng cấu trúc dữ liệu khác như Stack, Queue, LinkedList… Sử dụng thuần thục array là một trong những bí quyết giúp bạn giải quyết thành công nhiều bài toàn khó trong lập trình ứng dụng.