Một lỗi rất thường gặp khi triển khai web application được viết bằng framework hiện đại như Angular, React, Vue… là lỗi không thể copy-paste hoặc gõ vào ô địa chỉ của trình duyệt để truy cập trực tiếp một page cụ thể của webapp (ngoại trừ trang chủ). Tuy nhiên nếu từ trang chủ rồi nhấp vào menu để đi vào page ấy thì lại được. Vấn đề này chỉ xuất hiện khi triển khai thực tế trên hosting hoặc CDN, chứ không gặp với development server ở localhost.
Để biết nguyên nhân và cách khắc phục, trước hết chúng ta phải phân biệt được routing tại client-side và server-side.
Routing là gì?
Nói một cách ngắn gọn, routing (định tuyến) trong web application là quá trình tuyển chọn thành phần nào sẽ xử lý yêu cầu của người dùng, thông thường dựa vào URL. Vì URL căn bản là chuỗi (string) nên kỹ thuật routing chủ yếu là so sánh chuỗi, phân tích chuỗi rồi đưa ra quyết định với if-else hoặc switch-case.
Mỗi trường hợp được định nghĩa sẵn gọi là một route. Ví dụ, xét mã giả: Cho một URL, chọn ra hàm nào sẽ được gọi để xử lý URL đó.
// Định nghĩa sẵn 3 route
const routes = {
'/': showHomepage,
'/courses': showCourseList,
'/articles': showArticleList,
};
function doRouting(path) {
const handler = Object.keys(routes).find(r => r === path);
// Nếu không tìm thấy thì hiển thị trang lỗi 404
// Nếu tìm thấy route thì gọi function đã được đăng ký với route đó.
if (!handler)
showNotFound();
else
handler(req);
});
// Ví dụ URL là https://codeschool.vn/courses // khi đó `path` là '/courses'
doRouting(request.url.path);
function showHomepage() {...}
function showCourseList() {...}
function showArticleList() {...}
function showNotFound() {...}
Đây chỉ là ví dụ minh họa với trường hợp cực đơn giản, thực tế sẽ phức tạp hơn, ví dụ route /courses/:id
sẽ so khớp với path như /courses/88
hay /courses/fullstack-mern-pern
; Route /art*
sẽ khớp với path /art
, /artists/
và /articles
v.v.
Nếu hàm doRouting
này được chạy ở back-end thì gọi là Server-side routing, ngược lại nếu chạy trên trình duyệt web thì gọi là Client-side routing.
Định tuyến ở phía server (Server-side routing)
Đây là kỹ thuật routing truyền thống, căn bản nhất của lập trình web. Mã nguồn phía back-end sẽ dựa vào HTTP method (GET, POST, PUT…) và phần path của URL để quyết định function nào, hoặc method của class nào sẽ được gọi để xử lý request ấy.
Trong gói tin request của HTTP/1, dòng đầu tiên cho biết HTTP method và path mà request ấy nhắm đến.
GET /courses/88 HTTP/1.1 User-Agent: Mozilla/4.0 (compatible; MSIE5.01; Windows NT) Host: codeschool.vn Accept-Language: en-us ...
Trong gói tin request của HTTP/2, hai thông số này nằm trong header :method
và :path
. Nhưng hầu hết các framework web back-end đều có chức năng đọc gói tin và chuyển toàn bộ thông số thành object request
rồi, thậm chí xử lý luôn phần routing, nên điều duy nhất lập trình viên cần quan tâm là xử lý nghiệp vụ và trả kết quả có ý nghĩa về cho client.
Nếu web application chỉ có server-side routing, khi người dùng click vào menu để chuyển giao diện, trình duyệt sẽ tải trang web mới, mỗi giao diện có một URL riêng:
- Trang chủ:
http://webapp.com
hoặchttp://webapp.com/
- Các khóa học:
http://webapp.com/courses
- Các bài viết:
http://webapp.com/articles
- Nội dung bài viết:
http://webapp.com/article/88
hoặchttp://webapp.com/article/client-vs-serverside-routing
Khi click và giữ chuột trên nút Back, người dùng sẽ nhìn thấy lịch sử trình duyệt lưu lại những trang đã xem, nhấn nút Back và Forward sẽ di chuyển tới / lui trong danh sách history đó. Tiêu đề của từng dòng history chính là nội dung của thẻ <title>...</title>
.
Định tuyến ở phía client (Client-side routing)
Định tuyến với URL hash (Hash-based routing)
URL hash là phần đằng sau dấu #
của URL (còn gọi là phần fragment), phần này thường được dùng để nhảy đến một vị trí trên trang web, ví dụ https://codeschool.vn/portal/phan-biet-ky-thuat-routing-tai-client-side-va-server-side/#tong-ket, bởi vì phần này không được gửi lên server theo gói tin của request, mà chỉ có scheme (http), host (codeschool.vn) và path (/articles/client-vs-serverside-routing) mới được gửi, do đó việc thay đổi hash không khiến cho trình duyệt web tải trang mới.
Kỹ thuật này được áp dụng bởi các web app một trang (SPA—Single-page application) trong thời kỳ HTML 4, không cần lo vấn đề tương thích trình duyệt. Người dùng chỉ cần truy cập trang chủ http://webapp.com
, hành động này kích hoạt Server-side routing, server sẽ trả về mã nguồn của SPA để trình duyệt hiển thị lên web app. Từ thời điểm đó trở đi, khi người dùng click vào menu hoặc link để di chuyển giữa các page giao diện của SPA, ô địa chỉ của trình duyệt sẽ thay đổi như sau:
- Trang chủ:
http://webapp.com
hoặchttp://webapp.com#/
- Các khóa học:
http://webapp.com#/courses
- Các bài viết:
http://webapp.com#/articles
- Nội dung bài viết:
http://webapp.com#/article/88
Việc này do một hàm trong mã nguồn JavaScript của SPA thực hiện:
function navigate(path) {
// Xóa phần hash cũ đi
const current = window.location.href.replace(/#(.*)$/, '');
// Build lại URL chứa hash mới rồi gán vào ô địa chỉ
window.location.href = `${current}#${path}`;
}
Khi gán giá trị mới cho window.location.href
, trình duyệt sẽ tải trang web mới nếu phần scheme hoặc host hoặc path của URL bị thay đổi, nhưng trong trường hợp này chỉ có phần hash thay đổi nên trình duyệt sẽ đứng yên ở trang web hiện tại. Việc thay đổi giao diện do JS của SPA thực hiện.
Nếu người dùng mở tab mới rồi copy-paste hoặc gõ trực tiếp http://webapp.com#/articles
vào ô địa chỉ thì sao? Tất nhiên là sẽ kích hoạt Server-side routing để server trả về SPA, sau đó SPA sẽ đọc phần hash để quyết định hiển thị giao diện nào:
var hash = window.location.hash; // Vd: /articles
// Thực hiện client-side routing để chọn giao diện
doRouting(hash);
Nếu người dùng đang ở http://webapp.com#/articles
rồi chỉnh sửa ô địa chỉ thànhhttp://webapp.com#/courses
thì sao? Sau đó nếu người ấy nhấn nút Back của trình duyệt để quay lại http://webapp.com#/articles
thì sao? Quỡn hơn nữa, người ấy lại nhấn nút Forward của trình duyệt để đi tới http://webapp.com#/courses
thì sao? Chúng ta biết rằng trong cả 3 trường hợp này chỉ có phần hash thay đổi nên trình duyệt sẽ đứng yên ở trang hiện tại, nhưng nếu mã nguồn của SPA muốn phát hiện địa chỉ đã bị thay đổi, với HTML 4 cách duy nhất là theo dõi bằng cách hẹn giờ:
var prevHash;
function watchURL() {
var current = window.location.hash;
// Nếu hash có thay đổi
if (current !== prevHash) {
prevHash = current;
// Thực hiện client-side routing để chọn giao diện
doRouting(hash);
}
// Tiếp tục hẹn giờ để theo dõi
setTimeout(watchURL, 500);
};
watchURL();
Với HTML 5, chúng ta có thể lắng nghe sự kiện onhashchange
:
window.onhashchange = function(evt) {
// Thực hiện client-side routing để chọn giao diện
doRouting(evt.newURL);
};
Nhưng đã xài hàng mới thì phải mới cho trót, hay xem kỹ thuật tiếp theo đây…
Định tuyến với History API (Routing with History API)
Chuẩn HTML 5 cung cấp những hàm JavaScript mới hỗ trợ nhiều hơn cho SPA, trong đó có những hàm thao tác với lịch sử của trình duyệt (History API). Hầu như tất cả những thư viện và framework JS Front-end ngày nay (Angular, React, Vue…) đều áp dụng kỹ thuật này để thực hiện client-side routing.
Bước đầu tiên cũng giống như Hash-based routing: Người dùng truy cập trang chủ http://webapp.com
, kích hoạt Server-side routing, server trả về SPA để trình duyệt hiển thị lên web app. Từ thời điểm đó trở đi, mỗi khi người dùng click vào menu của web app, SPA sẽ hiển thị giao diện mới, đồng thời gọi hàm pushState
để trình duyệt ghi nhận vào lịch sử của nó, khiến cho nội dung trong ô địa chỉ thay đổi mà không tải trang web mới.
- Tới trang các khóa học:
history.pushState({ data: 'my data' }, "Title: Courses", "/courses")
- Tới trang các bài viết:
history.pushState({ data: 'my data' }, "Title: Articles", "/articles")
- Tới nội dung bài viết:
history.pushState({ data: 'my data' }, "Title: Client vs Serverside routing", "/article/88")
Người dùng sẽ nhìn thấy URL trong ô địa chỉ là http://webapp.com/courses
hoặc http://webapp.com/articles
. Khi click và giữ chuột trên nút Back, họ sẽ nhìn thấy history giống hệt như web app sử dụng 100% server-side routing đã nói ở mục trên, với tiêu đề trong history và trên tab của trình duyệt chính là tham số thứ 2 của hàm history.pushState
.
Để xử lý trường hợp nhấn nút Back và Forward, SPA cần lắng nghe sự kiện onpopstate
:
window.onpopstate = function(event) {
// Thực hiện client-side routing để chọn giao diện
doRouting(document.location.path);
};
Lợi ích của kỹ thuật này là URL trông rất tự nhiên, không có dấu hash #
:
- Trang chủ:
http://webapp.com
hoặchttp://webapp.com/
- Các khóa học:
http://webapp.com/courses
- Các bài viết:
http://webapp.com/articles
- Nội dung bài viết:
http://webapp.com/article/88
hoặchttp://webapp.com/article/client-vs-serverside-routing
Tuy vậy, rắc rối xảy ra khi người dùng gõ trực tiếp http://webapp.com/courses
vào ô địa chỉ, dẫn đến kích hoạt Server-side routing, mà ở Server lúc này chỉ có một route duy nhất là /
trả về SPA thôi, nên path /courses
sẽ bị lỗi 404 Not Found.
Cách giải quyết rất đơn giản một khi bạn đã hiểu nguồn gốc vấn đề: hãy thêm route ở server-side, sao cho tất cả các path đều chỉ có một route là trả về SPA (đường nào cũng về La Mã). Sau khi trình duyệt hiển thị SPA rồi, JS sẽ đọc path /courses
của URL và hiển thị đúng giao diện Danh sách các khóa học.
Cấu hình cho server Express của NodeJS:
Hãy sử dụng middleware connect-history-api-fallback:
const express = require('express');
const app = express();
app.use(history());
Cấu hình cho server Apache:
Hãy tạo file .htaccess tại thư mục gốc của SPA với nội dung như sau:
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>
Cấu hình cho server Nginx:
location / {
try_files $uri $uri/ /index.html;
}
Internet Information Services (IIS)
Nếu triển khai SPA trên IIS của Windows, hãy cài đặt IIS UrlRewrite, rồi tạo file web.config ở thư mục gốc của SPA có nội dung như sau:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
<rewrite>
<rules>
<rule name="Handle History Mode and custom 404/500" stopProcessing="true">
<match url="(.*)" />
<conditions logicalGrouping="MatchAll">
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
</conditions>
<action type="Rewrite" url="/" />
</rule>
</rules>
</rewrite>
</system.webServer>
</configuration>
Firebase hosting:
Hãy thêm vào file firebase.json
:
{
"hosting": {
"public": "dist",
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
}
Tổng kết
Server-side routing
- Back-end web app nào cũng có server-side routing, bất kể ở front-end có sử dụng client-side routing hay không.
- Được kích hoạt khi trình duyệt gửi yêu cầu lên server để tải mã nguồn trang web.
Client-side routing
Hash-based:
- Lợi dụng cách mà trình duyệt xử lý phần
#fragment
của URL. - Không cần cấu hình routing đặc thù gì ở server.
- Web app hiện đại không dùng kỹ thuật này nữa, trừ phi muốn hỗ trợ trình duyệt rất rất cũ.
History API:
- Thao tác với history của trình duyệt, hiển thị URL ở dạng tự nhiên.
- Cần phải cấu hình server-side routing cho tất cả route đều trả về mã nguồn SPA.
- Là kỹ thuật được các framework JS Front-end áp dụng chủ yếu hiện nay.
Nguồn: codeschool.vn