TypeScript là phiên bản mở rộng của JavaScript, giúp kiểm tra chặt chẽ kiểu dữ liệu ngay trong quá trình biên dịch. Đây là phần 2 của series "Hướng dẫn TypeScript Syntax cơ bản cho người mới".
Nếu đây là lần đầu tiên bạn đọc series này của 200Lab, mà lỡ lạc vào phần 2 thì các bạn có thể truy cập vào đây để theo dõi phần 1 nha. Trong phần 2 này mình sẽ đề cập về DOM, Type Casting, Modules, Interfaces, Generic.
1. DOM và các thao thác cơ bản với DOM
DOM (Document Object Model) là giao diện lập trình do trình duyệt triển khai. Thông qua DOM API, có thể thay đổi cấu trúc, kiểu dáng và nội dung của tài liệu HTML/XML. Các framework frontend như jQuery, React, Angular sử dụng DOM để làm cho website trở nên động và dễ phát triển.
TypeScript có quyền truy cập DOM như JavaScript nhưng bổ sung kiểu tĩnh (Static Typing) và kiểm tra lỗi trong quá trình phát triển. Điều này giúp TypeScript xử lý phần tử DOM an toàn và nghiêm ngặt hơn so với JavaScript. Để các bạn dễ hình dung hơn về sự nghiêm ngặt của TypeScript mình có một ví dụ nhỏ như sau:
const link = document.querySelector('a');
console.log(link.href); // ERROR: link có thể là 'null'
Ở đây, document.querySelector('a')
cố gắng chọn một phần tử liên kết (<a>
) từ DOM. Tuy nhiên, TypeScript báo lỗi vì:
- Phần tử có thể không tồn tại, và
querySelector
có thể trả vềnull
. - TypeScript cảnh báo rằng biến
link
có thể lànull
, và nếu truy cập thuộc tính củanull
(nhưhref
) có thể sẽ gây ra lỗi khi chạy chương trình.
Và giải pháp để giải quyết lỗi trên là các bạn có thể sử dụng toán tử khẳng định !
để TypeScript có thể hiểu là link
không phải là null
hoặc undefined
. Như mình đã đề cập tại phần 1, TypeScript đủ thông minh để suy luận kiểu của một biến mà không cần khai báo rõ ràng.
// Bây giờ chúng ta có thể truy cập vào <a> từ DOM mà không gặp lỗi
const link = document.querySelector('a')!;
console.log(link.href); // https://www.facebook.com/edu.200lab/
Trong ví dụ trên, khi sử dụng document.querySelector('a')
, TypeScript hiểu rằng bạn tìm phần tử thẻ <a>
, vì vậy Typescript sẽ suy luận và hiểu kiểu của link
là HTMLAnchorElement | null
.
1.1 Ép kiểu trong TypeScript (Type Casting)
Trước khi đi vào các phương thức cơ bản để thao tác với DOM, mình sẽ giới thiệu về ép kiểu (Type Casting) trong TypeScript. Khi làm việc với DOM, đôi khi cần ép kiểu để tránh gặp lỗi trong quá trình biên dịch. Mình sẽ lấy ví dụ cụ thể và giải thích cho các bạn về tình huống sử dụng và tại sao phải ép kiểu.
const form = document.getElementById('register-form');
console.log(form.method); // ERROR: Thuộc tính 'method' không tồn tại trên kiểu 'HTMLElement'
Trong ví dụ trên:
- TypeScript giả định
form
thuộc kiểuHTMLElement
(kiểu cơ sở cho tất cả các phần tử HTML). HTMLElement
không có thuộc tínhmethod
vìmethod
là thuộc tính chỉ có trongHTMLFormElement
.
Do đó, TypeScript không thể tự động suy ra kiểu chính xác của một phần tử DOM khi sử dụng các phương thức như getElementById
. Nó sẽ mặc định hiểu kiểu chung là (HTMLElement
). Để giải quyết vấn đề này, bạn cần ép kiểu phần tử đó thành kiểu dữ liệu cụ thể hơn, ví dụ: HTMLFormElement
.
Vì vậy, nếu bạn biết rõ kiểu của phần tử, hãy khai báo cụ thể để TypeScript hiểu.
const form = document.getElementById('register-form') as HTMLFormElement;
console.log(form.method); // post
1.2 Các phương thức cơ bản để thao tác với DOM trong TypeScript
1.2.1 Tìm và trả về phần tử với id
cụ thể.
element = document.getElementById("myId") as HTMLDivElement;
1.2.2 Tìm và trả về phần tử đầu tiên khớp với một CSS selector.
const element = document.querySelector(".myClass") as HTMLDivElement;
1.2.3 Tìm và trả về tất cả các phần tử khớp với một CSS selector dưới dạng NodeList
.
const elements = document.querySelectorAll(".myClass") as NodeListOf<HTMLDivElement>;
1.2.4 Tạo một phần tử HTML mới
const newElement = document.createElement("div");
// newElement có kiểu HTMLDivElement
1.2.5 Thêm một phần tử con vào phần tử hiện tại.
const parent = document.getElementById("parent") as HTMLDivElement;
const child = document.createElement("p"); parent.appendChild(child);
1.2.6 Xóa một phần tử con khỏi phần tử hiện tại.
const parent = document.getElementById("parent") as HTMLDivElement;
const child = document.getElementById("child") as HTMLParagraphElement;
parent.removeChild(child);
1.2.7 Đặt hoặc lấy nội dung HTML của một phần tử.
const element = document.getElementById("myDiv") as HTMLDivElement; element.innerHTML = "<p>Hello World</p>";
1.2.8 Đặt hoặc lấy nội dung văn bản của một phần tử.
const element = document.getElementById("myDiv") as HTMLDivElement; element.textContent = "Hello, World!";
1.2.9 Đặt một thuộc tính cho phần tử.
const element = document.getElementById("myDiv") as HTMLDivElement; element.setAttribute("class", "newClass");
1.2.10 Lấy giá trị của một thuộc tính từ phần tử.
const value = document.getElementById("myDiv")?.getAttribute("class");
1.2.11 Thêm một lớp CSS mới vào phần tử.
const element = document.getElementById("myDiv") as HTMLDivElement; element.classList.add("active");
1.2.12 Xóa một lớp CSS khỏi phần tử.
const element = document.getElementById("myDiv") as HTMLDivElement; element.classList.remove("active");
1.2.13 Thêm sự kiện lắng nghe cho phần tử với kiểm tra kiểu sự kiện.
const button = document.getElementById("myButton") as HTMLButtonElement; button.addEventListener("click", (event: MouseEvent) => { alert("Button clicked!"); });
1.2.14 Thay đổi trực tiếp các thuộc tính CSS của phần tử.
const element = document.getElementById("myDiv") as HTMLDivElement; element.style.backgroundColor = "red";
1.2.15 Xóa trực tiếp phần tử khỏi DOM.
const element = document.getElementById("myDiv") as HTMLDivElement; element.remove();
Đây là link của TypeScript Playground : https://www.typescriptlang.org/play/
Tất cả những ví dụ về Typescript của 200Lab các bạn chỉ cần copy
, paste
và đều có thể chạy thử ngay tại TS Playground mà không cần phải ngồi mất thời gian setup source.
const container = document.getElementsByClassName("navbar-sub")[0] as HTMLDivElement;
const p = document.createElement("p");
p.textContent = "Đây là một ví dụ dễ hiểu";
container.appendChild(p);
container.style.backgroundColor = "green";
container.classList.add("highlight");
container.addEventListener("click", (event: MouseEvent) => {
alert("Container clicked!");
});
Trong ví dụ trên, mình đã sử dụng các thao tác cơ bản với DOM:
- Thêm đoạn văn bản "Đây là một ví dụ dễ hiểu".
- Đổi màu nền của thanh navigation thành màu xanh.
- Bắt sự kiện
click
vào thanh navigation để hiện thông báo.
Mình hy vọng rằng từ ví dụ của mình các bạn cũng hình dung được phần nào cách sử dụng các phương thức trên.
2. Modules trong TypeScript
2.1 Export và Import Modules
Export và import các thành phần từ một module khác rất quan trọng. Đây là nền tảng để tổ chức và tái sử dụng mã nguồn.
2.1.2 Export Named
Trong file mathUtils.ts
:
export function add(a: number, b: number): number { return a + b; } export function subtract(a: number, b: number): number { return a - b; }
Trong file app.ts
:
import { add, subtract } from './mathUtils';
console.log(add(2, 3)); // 5
console.log(subtract(5, 2)); // 3
2.1.2 Export Default
Trong file logger.ts
:
export default function log(message: string): void {
console.log(message);
}
Trong file app.ts
:
import log from './logger';
log("Hello, world!"); // Hello, world!
2.2 Cấu hình tsconfig.json
Cấu hình TypeScript bằng file tsconfig.json
cơ bản, đặc biệt là các tùy chọn như module
và moduleResolution
. Điều này giúp bạn hiểu cách TypeScript biên dịch và xử lý các module. Chúng ta có file tsconfig.json
với cấu hình cơ bản sau:
{
"compilerOptions": {
"target": "es6",
"module": "es6",
"moduleResolution": "node",
"strict": true
}
}
target
: "es6": Chỉ định phiên bản ECMAScript sẽ được biên dịch.module: "es6"
: Sử dụng hệ thống module ES6.moduleResolution: "node"
: Cho phép TypeScript tìm module như cách Node.js tìm.
2.3 Dynamic Import (Import động)
Dynamic import cho phép bạn import các module chỉ khi cần thiết, thay vì tải tất cả ngay khi khởi động chương trình. Điều này tối ưu hóa hiệu suất bằng cách trì hoãn việc tải các module nặng hoặc chỉ tải những phần mã cần thiết vào thời điểm cụ thể.
Dynamic import được giới thiệu trong ECMAScript 2017 và TypeScript hỗ trợ tính năng này theo chuẩn JavaScript. Khi nào thì cần sử dụng Dynamic Import ?
- Lazy Loading: Chỉ tải module khi cần thiết, ví dụ như trong website, bạn có thể chỉ tải mã cho phần giao diện mà người dùng đang tương tác với nó.
- Tối ưu hoá hiệu xuất: Giảm kích thước bundle (gói) JavaScript ban đầu, giúp website tải nhanh hơn.
- Tải module có điều kiện: Bạn có thể quyết định tải module dựa trên điều kiện, ví dụ như thiết bị, trình duyệt, hoặc ngữ cảnh người dùng.
Ví dụ cho tải module có điều kiện của Dynamic Import:
- Giả sử trong file
mathUtils.ts
bạn đang có hàm tính nhân 2 số như sau:
export function multiply(a: number, b: number): number {
return a * b;
}
- Trong
app.ts
bạn có hàmcalculate()
để thực hiện phép tính:
async function calculate() {
const mathUtils = await import('./mathUtils');
console.log(mathUtils.multiply(3, 4)); // 12
}
loadMathUtils();
Khi sử dụng cú pháp dynamic import import('./mathUtils')
trong app.ts
, module mathUtils.ts
sẽ chỉ được tải khi hàm calculate()
được gọi. Điều này sẽ tránh việc tải module không cần thiết, nếu người dùng không thực hiện tính toán.
3. Interfaces trong TypeScript
Interfaces chắc hẳn không phải là khái niệm quá mới đối với các developer. Trong phần này, mình sẽ không định nghĩa lại nó mà sẽ tập trung vào những đặc điểm riêng của Interfaces trong TypeScript.
3.1 Interfaces trong TypeScript
Trong TypeScript, interface là cách để định nghĩa một kiểu dữ liệu mà các đối tượng phải tuân theo. Nó quy định các thuộc tính mà đối tượng phải có.
interface Readers {
firstName: string;
lastName: string;
}
function sayHi(person: Readers) {
return "Chào, " + person.firstName + " " + person.lastName;
}
let user = { firstName: "Thanh", lastName: "Nguyen" };
document.body.textContent = sayHi(user);
Readers
là một interface yêu cầu 2 trường thuộc tínhfirstName
,lastName
- Hàm
sayHi
có kiểu dữ liệu đầu vào làReaders
và return về một chuỗi sử dụng thuộc tính củaReaders
. - Khai báo
user
là một object (đối tượng) có 2 thuộc tínhfirstName
,lastName
.
Từ đây ta thấy một điểm khá đặc biệt, hàm sayHi
chấp nhận đối tượng user
mà không cần phải dùng từ khóa implements
.
Khi đọc tới đây nếu là một người đã hiểu biết về JavaScript có thể sẽ có một số thắc mắc như là: "JavaScript cũng làm được chuyện tương tự mà có gì đâu mà đặc biệt ?". Nếu các bạn đang có cùng thắc mắc như trên thì mình sẽ giải thích như sau:
- JavaScript có tính chất gọi là duck typing - một dạng kiểm tra cấu trúc đơn giản, cho phép kiểm tra đối tượng có các thuộc tính hoặc phương thức cần thiết để thực hiện tác vụ.
- Nếu đối tượng có những thuộc tính và phương thức cần thiết thì nó được coi là hợp lệ trong ngữ cảnh sử dụng.
- Nếu bạn cho đoạn code trên chạy bằng JavaScript thì thực chất JavaScript không kiểm tra xem đối tượng
user
có implements một interface nào hay không. Miễn làuser
có các thuộc tínhfirstName
vàlastName
, nó sẽ hoạt động đúng như mong đợi. Điều này tương tự về mặt hành vi so với tính năng dựa trên cấu trúc của TypeScript, nhưng JavaScript không kiểm tra hoặc đảm bảo tính nhất quán về kiểu dữ liệu như TypeScript.
3.2 Sự kết hợp giữa Interfaces và Classes
Từ ES6 TypeScript hỗ trợ lập trình hướng đối tượng dựa trên classes giống như JavaScript hiện đại.
Nếu ai đã theo dõi phần 1 thì mình đã nói khá là kĩ về classes trong TypeScript nên phần này mình chỉ nói về sự kết hợp của nó với interface như thế nào thôi nhé.
class Readers {
fullName: string;
constructor(
public firstName: string,
public middleInitial: string,
public lastName: string
) {
this.fullName = firstName + " " + middleInitial + " " + lastName;
}
}
interface User {
firstName: string;
lastName: string;
}
function sayHi(user: User) {
return "Hello, " + readers.firstName + " " + readers.lastName;
}
let readers = new Readers("Vy", "Thuy", "Tran");
document.body.textContent = sayHi(readers);
- Khởi tạo class
Readers
có thuộc tính làfullName
. Trong hàm khởi tạo (constructor) trong hàm này sẽ gán giá trị chofullName
từfirstName
,lastName
,middleInitial
. - Khi đánh dấu các tham số trong constructor là public thì nó sẽ tự động trở thành thuộc tính của object
Readers
. - Có thể thấy
readers
là một instance củaReaders
nhưng vẫn tương thích với interfacesUser
. Như ở phần trước, mình đã giải thích thì TypeScript sẽ tự động implement khi chúng có sự tương thích với nhau. Vậy nên hàm sayHi vẫn chấp nhậnreaders
.
Ở đây mình có một lưu ý cho các bạn. Khi các tham số firstName
, lastName
trong constructor bị đánh dấu là private
thì chúng sẽ lập tức xảy ra lỗi. Trong trường hợp này, vì firstName
và lastName
là hai thuộc tính chỉ có trong class Readers
và không thể truy cập từ bên ngoài, nên chúng không tham gia vào quá trình kiểm tra sự tương thích kiểu (type compatibility) của TypeScript.
class Readers {
fullName: string;
constructor(
private firstName: string,
public middleInitial: string,
public lastName: string
) {
this.fullName = firstName + " " + middleInitial + " " + lastName;
}
}
interface User {
firstName: string;
lastName: string;
}
function sayHi(user: User) {
return "Hello, " + readers.firstName + " " + readers.lastName;
//ERROR: Property 'firstName' is private and only accessible within class 'Readers'.
}
let readers = new Readers("Vy", "Thuy", "Tran");
document.body.textContent = sayHi(readers);
4. Generics trong TypeScript
Generics cho phép bạn tạo ra các thành phần (hàm, lớp,...) có thể làm việc với nhiều loại kiểu khác nhau thay vì chỉ một kiểu cụ thể. Điều này giúp thành phần trở nên linh hoạt và tái sử dụng hơn.
Thay vì thuyết phục các bạn bằng hàng loạt những thứ hay ho mà generics mang lại. Mình sẽ đưa cho các bạn một ví dụ cụ thể và dùng generics
để giải quyết vấn đề.
function createId(info: object){
let id = Math.floor(Math.random() * 200);
return { ...info, id };
};
let user = createId({ name: 'Vy', age: 22 });
console.log(user.id); //LOG: 170 (id in [0:200])
console.log(user.name); //ERROR: Property 'name' does not exist on type '{ id: number; }'.
Ở ví dụ trên:
- Hàm
createId
chấp nhận hết các dữ liệu có kiểuobject
. - Truyền một object
{ name: 'Thanh', age: 22 }
vàocreateId
và đối tượng này được kết hợp với thuộc tínhid
mới tạo. - Không có vấn đề khi truy cập vào thuộc tính id. Tuy nhiên, khi cố gắng truy cập vào một thuộc tính
name
thì xảy ra lỗi.
TypeScript báo lỗi khi bạn truyền một đối tượng vào hàm createId
mà không xác định rõ kiểu hoặc các thuộc tính của nó. Bởi vì, TypeScript không thể biết chắc chắn kiểu dữ liệu, nên không thể xác nhận sự tồn tại của các thuộc tính ngoài id
, dẫn đến lỗi khi cố gắng truy cập thuộc tính name
.
Điều này minh họa cho một tình huống phổ biến: việc truyền đối tượng vào một hàm mà không rõ ràng về kiểu dữ liệu sẽ gây ra các vấn đề trong TypeScript.
Để giải quyết vấn đề của ví dụ trên chúng ta sẽ áp dụng generics
. Cú pháp của generics
sẽ là <Type>
- trong đó Type
là tham số. Lưu ý là các bạn hoàn toàn có thể thay đổi tham số thành chuỗi kí tự chữ bất kì theo ý thích. Ở đây cho các bạn dễ hiểu thì mình đặt tên nó là Type
nha. Áp dụng generics
vào ví dụ trên:
function createId<Type>(info: Type){
let id = Math.floor(Math.random() * 200);
return { ...info, id };
};
let user = createId({ name: 'Thanh', age: 22 });
console.log(user.id); //LOG: 150 (id in [0:200])
console.log(user); // LOG: {"name": "Thanh", "age": 22, "id": 150}
console.log(user.name) //LOG: Thanh
Khi áp dụng generics
vào ví dụ ban đầu thì đã không còn xảy ra lỗi nữa, nhưng nó chưa đầy đủ, lúc này TypeScript chấp nhận mọi kiểu dữ liệu truyền vào. Chỉ xảy ra lỗi khi bạn cố truy cập vào những thuộc tính không tồn tại.
Do đó, chúng ta nên ràng buộc kiểu dữ liệu bằng cách sử dụng <Type extends object>
. Điều này giúp ràng buộc về kiểu dữ liệu, nhưng chưa thực sự chặt chẽ nếu bạn là chỉ có ý định chấp nhận truyền vào object (có cả key và value). Cho bạn nào chưa biết thì trong JavaScript và TypeScript thì mảng (Array List) cũng là một object.
Vì thế để đoạn code thật sự chặt chẻ hơn thì mọi người nên làm như này:
//Tuy rằng đã ràng buộc bằng object như array list vẫn có thể bypass
function createIdExtendO<Type extends object>(info: Type){
let id = Math.floor(Math.random() * 200);
return { ...info, id };
};
let user = createIdExtendO({ name: 'Thanh', age: 22 });
let user1 = createIdExtendO(['Thanh', 'Vy']);
console.log(user); // [LOG]: {"name": "Thanh", "age": 22, "id": 156}
console.log(user1) // [LOG]: {"0": "Thanh","1": "Vy","id": 139}
// Ràng buộc cho dữ liệu truyền vào có kiểu {name: string}
function createIdFix<Type extends {name: string}>(info: Type){
let id = Math.floor(Math.random() * 200);
return { ...info, id };
};
let user3 = createIdFix({ name: 'Thanh', age: 22 });
let user4 = createIdFix(['Thanh', 'Vy']); // ERROR: Argument of type 'string[]' is not assignable to parameter of type '{ name: string; }'.
console.log(user3); // LOG: {"name": "Thanh", "age": 22, "id": 150}
Ngoài cách cho Type
extends một object có thuộc tính cụ thể như trên, thay vào đó có thể extends một interface để đối số truyền vào đều phải có những thuộc tính trong interface.
interface userName{
name: string;
}
function createIdExtendI<Type extends userName>(info: Type){
let id = Math.floor(Math.random() * 200);
return { ...info, id };
};
let userFix = createIdExtendI({ name: 'Thanh', age: 22 });
console.log(userFix); // LOG: {"name": "Thanh", "age": 22, "id": 150}
Ngoài việc dùng generic
để ràng buộc đối số truyền vào hàm, chúng ta còn có thể áp dụng nó vào interface
. Sử dụng generics trong interface
giúp tăng tính linh hoạt và khả năng tái sử dụng của code, tương tự như khi sử dụng với các hàm.
interface Box<Type> {
content: Type;
}
let stringBox: Box<string> = { content: "Hello, world!" };
let numberBox: Box<number> = { content: 42 };
Trong ví dụ, khi sử dụng generics chúng ta có thể tuỳ biến kiểu dữ liệu của thuộc tính content. Như vậy, việc sử dụng generics trong interface giúp đảm bảo rằng dữ liệu bên trong interface luôn nhất quán và phù hợp với kiểu mà bạn đã khai báo trước đó.
5. Tổng kết
TypeScript mang đến những công cụ mạnh mẽ giúp tăng cường tính chắc chắn và khả năng mở rộng. Bằng cách tận dụng các khái niệm như thao tác DOM, type casting, modules, interfaces và generics. TypeScript giúp code của bạn dễ dàng bảo trì, chặt chẽ về kiểu dữ liệu.
Việc giới thiệu tính an toàn kiểu dữ liệu, cùng với các tính năng như generics giúp linh hoạt hơn, mở ra những khả năng mới trong việc viết các thành phần tái sử dụng và nhất quán. Hiểu rõ những nguyên tắc cốt lõi này là bước tiến quan trọng để thành thạo TypeScript, là nền tảng cho bạn xây dựng ứng dụng hiệu quả và đáng tin cậy hơn.
Một số các bài viết với các chủ đề đang được quan tâm nhiều tại 200Lab:
Bài viết liên quan
React Toastify là gì? Hướng dẫn sử dụng Toast Notification với React Toastify
Nov 21, 2024 • 7 min read
Hướng dẫn sử dụng Zustand trong NextJS
Nov 21, 2024 • 8 min read
Lazy Loading: Kỹ thuật Tối ưu Hiệu suất Website
Nov 17, 2024 • 14 min read
Hướng dẫn sử dụng Redux Toolkit và Redux Saga trong dự án React
Nov 15, 2024 • 10 min read
WebGL là gì? Hướng dẫn tạo đồ họa đơn giản với WebGL
Nov 13, 2024 • 7 min read
Test-Driven Development (TDD) là gì? Hướng dẫn thực hành TDD
Nov 13, 2024 • 6 min read