Angular Learning
Câu hỏi phỏng vấn

NgModule và standalone component khác nhau thế nào?

Câu trả lời phỏng vấn về component thuộc NgModule và standalone component: khai báo, imports, providers, bootstrap, lazy loading và chuyển đổi.

Trả lời ngắn

Angular không có khái niệm chính thức là “module component”. Cách gọi này thường chỉ component được khai báo trong một NgModule. Standalone component không cần nằm trong declarations của NgModule; nó tự khai báo các dependency dùng trong template qua imports. Khác biệt chính là phạm vi biên dịch template và cách tổ chức mã, không phải cách Angular tạo component hay inject service.

Mục lục

Phân biệt đúng hai khái niệm

Một component thuộc NgModule là component được đặt trong declarations của một module:

@NgModule({
  declarations: [UserCardComponent],
})
export class UsersModule {}

Component này không tự mang phạm vi template của nó. Angular xem UsersModule để biết template của UserCardComponent được phép dùng directive, pipe và component nào.

Một standalone component khai báo phạm vi template ngay trong metadata của component:

@Component({
  selector: 'app-user-card',
  standalone: true,
  imports: [DatePipe],
  templateUrl: './user-card.component.html',
})
export class UserCardComponent {}

Từ Angular 19, standalone mặc định là true với component mới. Khi đọc mã hiện đại, không thấy standalone: true không có nghĩa component đó thuộc NgModule; hãy kiểm tra xem nó có standalone: false hay có nằm trong declarations của module hay không.

Đừng nhầm `NgModule` với ES module

NgModule là class có decorator @NgModule của Angular. Còn import { Component } from '@angular/core' là cú pháp module của TypeScript/JavaScript. Hai chữ “module” này không cùng một khái niệm.

Câu trả lời dùng trong phỏng vấn

Component thuộc NgModule phải được khai báo trong declarations, còn directive, pipe và component mà template của nó dùng được lấy từ imports của NgModule. Standalone component không cần NgModule; nó đưa các dependency của template vào imports của chính component. Standalone giúp dependency hiện gần nơi dùng hơn và lazy load component trực tiếp. Nó không thay đổi bản chất của component, template hay DI. NgModule vẫn được Angular hỗ trợ và hai cách có thể cùng tồn tại trong một ứng dụng.

Nếu người phỏng vấn hỏi sâu hơn, nói thêm rằng providers là một vấn đề riêng: vị trí đăng ký provider quyết định scope của service, không phải việc component có standalone hay không.

So sánh nhanh

Nội dungComponent thuộc NgModuleStandalone component
Khai báo componentPhải có trong declarations của đúng một NgModule.Không nằm trong declarations của NgModule.
Dependency của templateLấy từ imports của NgModule chứa component.Khai báo trong imports của component.
Dùng ở nơi khácImport NgModule đã export component đó.Import trực tiếp component vào imports của component cha, hoặc import vào NgModule.
Bootstrap ứng dụngThường là bootstrapModule(AppModule).Thường là bootstrapApplication(AppComponent, appConfig).
Lazy loadingThường dùng loadChildren để tải module.Có thể dùng loadComponent để tải trực tiếp page component.
Provider dùng chungThường cấu hình ở providers của module hoặc providedIn: 'root'.Thường cấu hình qua ApplicationConfig, route providers, component providers hoặc providedIn: 'root'.
Mức độ hỗ trợ hiện tạiVẫn được hỗ trợ, cần thiết khi làm với mã hoặc thư viện cũ.Là hướng mặc định cho ứng dụng mới.

Cùng một màn hình, hai cách khai báo

Ví dụ dưới đây đều tạo một UserCardComponent hiển thị tên và ngày tham gia của người dùng. Điều thay đổi là nơi Angular lấy các dependency cho template.

Cách dựa trên NgModule

UserCardComponent là component không standalone. Nó cần được khai báo trong một module.

// user-card.component.ts
import { Component, Input } from '@angular/core';

export interface User {
  name: string;
  joinedAt: Date;
}

@Component({
  selector: 'app-user-card',
  standalone: false,
  template: `
    <article>
      <h2>{{ user.name }}</h2>
      <p>Tham gia: {{ user.joinedAt | date: 'dd/MM/yyyy' }}</p>
    </article>
  `,
})
export class UserCardComponent {
  @Input({ required: true }) user!: User;
}

Pipe date không được khai báo ở component. UsersModule đưa CommonModule vào phạm vi template của các component mà module khai báo.

// users.module.ts
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { UserCardComponent } from './user-card.component';

@NgModule({
  declarations: [UserCardComponent],
  imports: [CommonModule],
  exports: [UserCardComponent],
})
export class UsersModule {}

Một module khác muốn dùng <app-user-card> phải import UsersModule:

@NgModule({
  imports: [CommonModule, UsersModule],
})
export class ProfileModule {}

Luồng phụ thuộc của template là:

direction: right

n0: "UserCardComponent" {
  width: 160
  height: 58
}

n1: "UsersModule\ndeclarations" {
  width: 130
  height: 58
}

n2: "CommonModule" {
  width: 130
  height: 58
}

n0 -> n1: "Khai báo trong"
n1 -> n2: "imports"
UserCardComponentUsersModuledeclarationsCommonModule Khai báo trong imports
UserCardComponentUsersModuledeclarationsCommonModule Khai báo trong imports
NgModule khai báo component và import dependency
direction: right

n0: "CommonModule\nDatePipe, NgIf, NgFor" {
  width: 192
  height: 58
}

n1: "Template\nUserCardComponent\nDùng được date" {
  width: 160
  height: 79
}

n0 -> n1: "Cung cấp"
CommonModuleDatePipe, NgIf, NgForTemplateUserCardComponentDùng được date Cung cấp
CommonModuleDatePipe, NgIf, NgForTemplateUserCardComponentDùng được date Cung cấp
CommonModule cung cấp pipe và directive cho template

exports là phần dễ quên. declarations chỉ làm component dùng được bên trong UsersModule; muốn module khác dùng nó, module phải export component đó.

Đọc declarations, imports và exports

Ba mảng này trả lời ba câu hỏi khác nhau:

MảngCâu hỏi nó trả lờiTrong ví dụ UsersModule
declarationsModule này sở hữu component, directive và pipe nào?UserCardComponent thuộc UsersModule.
importsTemplate của các declaration trong module được dùng thêm API UI nào?CommonModule làm DatePipe, NgIf, NgFor... dùng được.
exportsModule khác import module này thì được dùng API UI nào?Module khác dùng được UserCardComponent.

imports không có nghĩa là chỉ một component trong declarations được dùng CommonModule. Mọi component, directive và pipe trong declarations của UsersModule đều nhận được phạm vi template từ CommonModule.

@NgModule({
  declarations: [
    UserCardComponent,
    UserListPageComponent,
  ],
  imports: [CommonModule],
  exports: [UserCardComponent],
})
export class UsersModule {}

Trong ví dụ này:

  • UserCardComponent và UserListPageComponent đều dùng được *ngIf, *ngFor, date vì UsersModule import CommonModule.
  • Chỉ UserCardComponent được module khác dùng, vì chỉ component này có trong exports.
  • UserListPageComponent vẫn dùng bình thường bên trong UsersModule, nhưng module khác không được đặt <app-user-list-page> trong template của họ.

Một component không standalone phải nằm trong declarations của đúng một NgModule. Nếu khai báo cùng một component ở hai module, Angular báo lỗi vì không biết module nào sở hữu compilation scope của component đó.

exports cũng có thể export lại API từ module đã import. Đây là kiểu SharedModule thường gặp trong mã Angular cũ:

@NgModule({
  imports: [CommonModule],
  exports: [CommonModule],
})
export class SharedModule {}

Module import SharedModule sẽ dùng được các API template mà CommonModule export. Cách này tiện khi nhiều module cùng cần một tập directive và pipe, nhưng đừng biến SharedModule thành nơi gom mọi thứ không liên quan.

Cách standalone

Với standalone, UserCardComponent import trực tiếp pipe mà template cần. Không cần UsersModule chỉ để khai báo và export một component.

// user-card.component.ts
import { DatePipe } from '@angular/common';
import { Component, input } from '@angular/core';

export interface User {
  name: string;
  joinedAt: Date;
}

@Component({
  selector: 'app-user-card',
  imports: [DatePipe],
  template: `
    <article>
      <h2>{{ user().name }}</h2>
      <p>Tham gia: {{ user().joinedAt | date: 'dd/MM/yyyy' }}</p>
    </article>
  `,
})
export class UserCardComponent {
  readonly user = input.required<User>();
}

Component cha import trực tiếp UserCardComponent:

import { Component } from '@angular/core';
import { UserCardComponent } from './user-card.component';

@Component({
  selector: 'app-profile-page',
  imports: [UserCardComponent],
  template: `<app-user-card [user]="user" />`,
})
export class ProfilePageComponent {
  readonly user = {
    name: 'An',
    joinedAt: new Date('2025-01-15'),
  };
}

Luồng phụ thuộc ngắn hơn:

direction: right

n0: "ProfilePageComponent" {
  width: 184
  height: 58
}

n1: "UserCardComponent" {
  width: 160
  height: 58
}

n2: "DatePipe" {
  width: 130
  height: 58
}

n3: "Template dùng date" {
  width: 168
  height: 58
}

n0 -> n1: "imports"
n1 -> n2: "imports"
n2 -> n3
ProfilePageComponentUserCardComponentDatePipeTemplate dùng date imports imports
ProfilePageComponentUserCardComponentDatePipeTemplate dùng date imports imports
Dependency template của standalone component

Standalone không có nghĩa mỗi component phải tự import mọi thứ một cách máy móc. Một component có thể import một standalone component khác, hoặc import một NgModule cũ khi cần dùng các declaration mà module đó export.

Angular tìm dependency cho template ra sao

Sự khác nhau cốt lõi nằm ở compilation scope, tức tập directive, pipe và component mà Angular cho phép xuất hiện trong template.

Với component thuộc NgModule, Angular tạo scope từ module:

  • declarations: các component, directive và pipe mà module sở hữu.
  • imports: các API template mà module nhận từ module khác.
  • exports: các API template mà module cho module khác dùng.

Với standalone component, Angular tạo scope từ imports của component. Mảng này có thể chứa standalone component, standalone directive, standalone pipe hoặc NgModule.

Ví dụ, hai đoạn sau đều hợp lệ nhưng thuộc hai cơ chế khác nhau:

// NgModule: NgIf đến từ CommonModule mà module imports.
@NgModule({
  declarations: [OrdersPageComponent],
  imports: [CommonModule],
})
export class OrdersModule {}
// Standalone: NgIf đến từ imports của chính component.
@Component({
  imports: [NgIf],
  template: `<p *ngIf="isLoading">Đang tải...</p>`,
})
export class OrdersPageComponent {
  isLoading = true;
}

Nếu quên NgIf trong imports của standalone component, Angular không biết *ngIf là directive nào. Lỗi này tương đương với việc quên import CommonModule vào NgModule trong kiến trúc cũ.

providers và DI có thay đổi không?

Không. Standalone thay đổi cách ghép dependency cho template, còn DI vẫn dùng token, provider và injector như nhau.

@Injectable({ providedIn: 'root' })
export class UserApi {
  // Một instance cho application injector.
}

Service trên dùng được trong cả component thuộc NgModule lẫn standalone component:

@Component({
  selector: 'app-user-card',
  template: `...`,
})
export class UserCardComponent {
  private readonly userApi = inject(UserApi);
}

Các scope provider cần nhớ:

Vị trí đăng kýHệ quả
providedIn: 'root' hoặc application configThường có một instance trong toàn ứng dụng.
providers của routeInstance sống theo route đang hoạt động.
providers của componentComponent đó và cây con có instance riêng.
providers của NgModuleCó scope theo injector nơi module được nạp; cần đọc kỹ với eager và lazy module.

Câu trả lời dễ bị hỏi tiếp

Standalone component không tự làm service thành singleton. Service là singleton hay không phụ thuộc vào provider được đăng ký ở root, route hay component.

Bootstrap và router

Đây là hai việc xảy ra nối tiếp nhau, nhưng không phải một việc:

  1. Bootstrap tạo ứng dụng Angular và render root component, thường là AppComponent.
  2. Router đọc URL hiện tại, chọn route phù hợp rồi render page vào <router-outlet> bên trong root component.
direction: right
main: "Trình duyệt tải main.ts"
bootstrap: "Bootstrap tạo ứng dụng\nvà AppComponent"
outlet: "AppComponent\ncó <router-outlet>"
main -> bootstrap
bootstrap -> outlet
Trình duyệt tải main.tsBootstrap tạo ứng dụngvà AppComponentAppComponentcó <router-outlet>
Trình duyệt tải main.tsBootstrap tạo ứng dụngvà AppComponentAppComponentcó <router-outlet>
Bootstrap và Router tạo page vào outlet (1/2)
direction: right
outlet: "AppComponent\ncó <router-outlet>"
router: "Router khớp URL\nVí dụ /users"
page: "Router tải và render\nUsersPageComponent\nvào router-outlet"
outlet -> router
router -> page
AppComponentcó <router-outlet>Router khớp URLVí dụ /usersRouter tải và renderUsersPageComponentvào router-outlet
AppComponentcó <router-outlet>Router khớp URLVí dụ /usersRouter tải và renderUsersPageComponentvào router-outlet
Bootstrap và Router tạo page vào outlet (2/2)

Bootstrap chỉ khởi động ứng dụng

Trong kiến trúc NgModule, Angular khởi động từ root module. Module này nói Angular component nào là root component qua mảng bootstrap.

// main.ts
platformBrowserDynamic().bootstrapModule(AppModule);
// app.module.ts
@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, AppRoutingModule],
  bootstrap: [AppComponent],
})
export class AppModule {}

Ý nghĩa của đoạn trên là: Angular tạo AppModule, đọc bootstrap: [AppComponent], rồi tạo AppComponent và gắn nó vào phần tử <app-root> trong index.html.

Với standalone, không cần AppModule làm bước trung gian. Angular nhận trực tiếp root component:

// main.ts
bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes),
    provideHttpClient(),
  ],
});

bootstrapApplication(AppComponent, ...) cũng tạo application injector. Mảng providers ở đây là nơi đăng ký các service dùng cho toàn app, chẳng hạn Router và HttpClient.

RouterOutlet và provideRouter làm hai việc khác nhau

Đây là cặp API rất dễ nhầm:

APIVai tròĐặt ở đâu trong standalone app?
RouterOutletDirective tạo vị trí trong template để Router render page hiện tại.imports của AppComponent, vì nó được dùng trong template.
provideRouter(routes)Đăng ký Router và cấu hình route vào DI của application.providers khi gọi bootstrapApplication().

Root component standalone thường có dạng sau:

// app.component.ts
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';

@Component({
  selector: 'app-root',
  imports: [RouterOutlet],
  template: `<router-outlet />`,
})
export class AppComponent {}

Nếu chỉ có provideRouter(routes) mà AppComponent không có <router-outlet>, Router vẫn biết URL nhưng không có chỗ trong giao diện để đặt page. Ngược lại, chỉ thêm <router-outlet> mà không gọi provideRouter(routes) thì Angular không có Router service và cấu hình route.

Trong app dựa trên NgModule, hai việc này thường bị che bởi AppRoutingModule:

// app-routing.module.ts
const routes: Routes = [
  { path: 'users', loadChildren: () => import('./users/users.module').then((m) => m.UsersModule) },
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule],
})
export class AppRoutingModule {}

RouterModule.forRoot(routes) đăng ký Router. exports: [RouterModule] làm router-outlet, routerLink và các directive router khác dùng được trong template của AppModule.

Router tải trang theo URL

Trong standalone app, route có thể tải một page component trực tiếp:

// app.routes.ts
export const routes: Routes = [
  {
    path: 'users',
    loadComponent: () =>
      import('./users-page.component').then((m) => m.UsersPageComponent),
  },
];

Khi người dùng vào /users, Router mới chạy import(...), lấy UsersPageComponent, tạo component đó và đặt nó vào <router-outlet>. Đây là lazy loading: mã của page không cần nằm trong JavaScript tải ban đầu.

loadChildren vẫn dùng được. Trong mã cũ, nó thường tải một lazy NgModule:

{
  path: 'admin',
  loadChildren: () =>
    import('./admin/admin.module').then((m) => m.AdminModule),
}

Trong mã standalone hiện đại, loadChildren cũng có thể tải một mảng routes của cả feature. Dùng loadComponent khi route chỉ cần một page; dùng loadChildren khi feature có nhiều route con như /admin/users, /admin/roles và /admin/settings.

NgModule và standalone có dùng chung được không?

Có. Đây là điều quan trọng khi nâng cấp ứng dụng cũ.

  • Một standalone component có thể import NgModule để dùng các component, directive và pipe mà module export.
  • Một NgModule có thể import standalone component, directive hoặc pipe.
  • Router có thể có route tải module cũ bằng loadChildren và route tải standalone page bằng loadComponent trong cùng một ứng dụng.
  • importProvidersFrom() cho phép đưa provider của module cũ vào application config khi cần tương thích.

Ví dụ module cũ dùng standalone component:

@NgModule({
  imports: [CommonModule, UserCardComponent],
  declarations: [LegacyProfilePageComponent],
})
export class LegacyProfileModule {}

Vì thế không cần dừng toàn bộ dự án để “chuyển sang standalone”. Có thể chuyển từng feature hoặc từng route.

Khi nào nên chọn cách nào

Ứng dụng mới

Chọn standalone làm mặc định. Angular hiện đại tạo component mới theo hướng này, bootstrap và route cũng có API trực tiếp. Mã ít lớp trung gian hơn, đặc biệt với feature nhỏ và lazy route.

Ứng dụng đang dùng NgModule

Không cần đổi chỉ vì standalone mới hơn. Hãy ưu tiên chuyển khi đang sửa feature, cần giảm độ phức tạp của shared module, hoặc muốn dùng cách bootstrap/router mới. Nếu ứng dụng ổn định và module boundaries rõ ràng, việc chuyển đổi có thể làm dần.

Thư viện hoặc dependency cũ

Bạn vẫn có thể gặp thư viện chỉ export NgModule. Standalone component import module đó bình thường. Không cần tạo wrapper module trừ khi muốn gom một tập dependency UI được dùng lặp lại.

Các câu hỏi gài thường gặp

Standalone có thay thế hoàn toàn NgModule không?

Không. Standalone là cách được ưu tiên cho mã mới, nhưng NgModule vẫn được Angular hỗ trợ. Nó còn xuất hiện nhiều trong dự án cũ và thư viện.

Standalone có phải là component không có module TypeScript không?

Không. Mọi file TypeScript vẫn dùng import/export bình thường. “Standalone” chỉ nói component không phụ thuộc vào NgModule của Angular để tham gia compilation scope.

Có phải standalone component không được dùng trong NgModule?

Không. NgModule có thể đặt standalone component trong imports. Không đặt nó trong declarations.

Có cần thêm CommonModule vào mọi standalone component không?

Không bắt buộc. Bạn có thể import trực tiếp NgIf, NgFor, AsyncPipe, DatePipe hoặc API standalone mà template cần. Import CommonModule vẫn hợp lệ khi dùng nhiều directive/pipe phổ biến.

imports của standalone component có phải là DI provider không?

Không. imports tạo phạm vi cho template. Provider được đặt ở providers, providedIn, application config hoặc route config.

Standalone có làm bundle nhỏ hơn ngay lập tức không?

Không thể kết luận chỉ từ chữ standalone. Bundle còn phụ thuộc vào route splitting, dependency thực tế, tree-shaking và cấu hình build. Lợi ích rõ hơn là dependency của template dễ nhìn và lazy-load component trực tiếp hơn.

Lộ trình chuyển từ NgModule sang standalone

Chuyển theo feature để thay đổi nhỏ và dễ kiểm tra.

  1. Nâng Angular và các thư viện lên phiên bản hỗ trợ standalone.
  2. Chuyển leaf component trước: thêm standalone: true, bỏ component khỏi declarations, rồi đưa các dependency template vào imports.
  3. Cập nhật component/module đang dùng nó: standalone component đi vào imports, không đi vào declarations.
  4. Chuyển một route sang loadComponent nếu route đó chỉ tải một page.
  5. Chuyển bootstrap sang bootstrapApplication() sau khi các provider cấp ứng dụng đã được xác định rõ.
  6. Chạy test và kiểm tra đặc biệt các provider trước đây được đăng ký trong module lazy.

Điểm dễ tạo lỗi khi migration

Đừng chuyển providers từ lazy NgModule lên application config theo thói quen. Việc này có thể biến service theo feature thành singleton toàn ứng dụng, làm state không được reset khi rời route.

Tóm tắt để nhớ

  • Component thuộc NgModule: khai báo ở declarations; template nhận dependency qua imports của module.
  • Standalone component: không cần declarations; template nhận dependency qua imports của component.
  • imports phục vụ template; providers phục vụ DI. Hai mảng không thay thế nhau.
  • Standalone không bỏ NgModule, không thay đổi component lifecycle và không tự thay đổi scope của service.
  • Với app mới, dùng standalone làm mặc định. Với app cũ, chuyển dần theo feature và giữ nguyên những module đang hoạt động tốt.

On this page