Xóa thẻ HTML khỏi chuỗi trong JavaScript: đừng dùng regex, và cũng đừng dùng div
Thỉnh thoảng mình lại gặp một yêu cầu nghe rất đơn giản: lấy một đoạn HTML và trả về chữ thuần. Ví dụ trang danh sách bài viết cần hiển thị đoạn trích ngắn của từng bài, nhưng nội dung gốc lưu trong database lại là HTML từ trình soạn thảo. Mình chỉ cần phần chữ, không cần <p>, <strong> hay <img>.
Bài này ghi lại vài điều nên làm và không nên làm khi viết hàm stripHtml(), dựa trên một bài viết của blog Webventures năm 2022 và tài liệu MDN về DOMParser. Mình có sửa lại hai chỗ trong đoạn code gốc vì có lỗi.
Phạm vi ở đây là trường hợp "bỏ hết HTML, chỉ giữ chữ". Nếu bạn cần giữ lại một phần thẻ an toàn để hiển thị lại, hãy dùng thư viện sanitize như DOMPurify thay vì tự viết.
1. Đừng dùng regex
Cách đầu tiên ai cũng nghĩ tới:
const plain = html.replace(/<[^>]+>/g, '');
Với chuỗi đơn giản thì nó chạy được. Nhưng HTML có rất nhiều trường hợp lạ: comment <!-- -->, thuộc tính chứa dấu >, thẻ viết sai, entity như & hay vẫn nằm nguyên trong kết quả. Trình duyệt đã có sẵn một bộ parse HTML xử lý được tất cả những thứ đó, nên tốt nhất là để nó làm.
2. Cách cũ dùng div có lỗ hổng XSS
Trước đây mình hay viết thế này:
function stripHtml(html) {
const tmp = document.createElement('div');
tmp.innerHTML = html;
return tmp.innerText || '';
}
Kết quả trả về đúng là chữ thuần. Vấn đề nằm ở lúc gán innerHTML cho một div: trình duyệt sẽ parse và xử lý HTML đó như nội dung thật. Thử với chuỗi này:
stripHtml(`
<p>Xin chào!</p>
<img src="khong-ton-tai.png" onerror="alert('Dính rồi!')">
`);
Ảnh không tải được, onerror chạy, và bạn có một lỗi XSS ngay trong một hàm tưởng là vô hại. Nếu dữ liệu đến từ người dùng hoặc từ hệ thống bên ngoài thì đây là rủi ro thật.
3. Dùng template thay cho div
Phần tử <template> cũng parse HTML, nhưng nội dung bên trong là "trơ": ảnh không được tải, script không chạy, onerror không được gọi.
function stripHtml(html) {
const tmp = document.createElement('template');
tmp.innerHTML = html;
return tmp.content.textContent || '';
}
Lưu ý là phải đọc từ tmp.content, không phải từ tmp. Đoạn code trong bài gốc dùng tmp.content.innerText, nhưng tmp.content là một DocumentFragment, mà DocumentFragment không có thuộc tính innerText, nên kết quả luôn là chuỗi rỗng. Dùng textContent mới đúng.
4. Hoặc dùng DOMParser
MDN mô tả DOMParser.parseFromString() với kiểu text/html sẽ tạo ra một document riêng, trong đó script không được thực thi. Cách này cũng an toàn và đọc code dễ hiểu:
function stripHtml(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
return doc.body.textContent || '';
}
Bài gốc có nhắc cách này chậm hơn <template>. Nếu chỉ xử lý một số đoạn trích ngắn thì chênh lệch đó thường không đáng kể, nên chọn cách nào cũng được. Có một điểm MDN nhấn mạnh: DOMParser chỉ parse, không sanitize. Nếu sau đó bạn lại chèn HTML vào trang thì vẫn phải lọc riêng.
5. textContent lấy cả nội dung của script và style
textContent nhanh vì nó không quan tâm phần tử có hiển thị hay không. Hệ quả là nội dung bên trong <script> và <style> cũng bị lấy ra:
stripHtml(`
<style>p { color: red; }</style>
<p>Xin chào</p>
`);
// "p { color: red; }Xin chào"
Với innerText thì vấn đề này không xảy ra trên phần tử đang hiển thị, nhưng nội dung trong template hay trong document của DOMParser không được render, nên mình thấy cách chắc ăn nhất là xóa các thẻ đó trước khi đọc chữ:
function stripHtml(html) {
const tmp = document.createElement('template');
tmp.innerHTML = html;
tmp.content.querySelectorAll('script, style, noscript').forEach((el) => el.remove());
return tmp.content.textContent.replace(/\s+/g, ' ').trim();
}
Dòng replace(/\s+/g, ' ') cuối cùng gom các khoảng trắng và xuống dòng thừa lại, để đoạn trích không bị lởm chởm. Regex ở đây chỉ xử lý chữ thuần, không đụng vào HTML, nên không có vấn đề gì.
6. Khi chỉ cần làm một lần
Không phải lúc nào cũng cần viết code. Nếu bạn chỉ cần làm sạch vài chục mô tả sản phẩm copy từ một trang cũ để dán vào bảng tính, một công cụ xóa thẻ HTML online có thể làm việc đó ngay trong trình duyệt, kể cả chế độ chỉ xóa hoặc chỉ giữ một số thẻ nhất định. Trang đó còn có vài công cụ xử lý văn bản khác như sắp xếp dòng theo bảng chữ cái. Còn khi việc làm sạch là một phần của ứng dụng, chạy mỗi lần có bài mới, thì hàm stripHtml() ở trên vẫn là lựa chọn hợp lý hơn vì bạn kiểm soát và test được nó.
Tóm lại
- Không dùng regex để parse HTML.
- Không gán
innerHTMLvàodivvới dữ liệu bạn không kiểm soát. - Dùng
<template>hoặcDOMParser, đọctextContent. - Xóa
script,styletrước nếu nội dung có thể chứa chúng. - Cần giữ lại một phần HTML để hiển thị thì dùng thư viện sanitize.
Tham khảo: Webventures, "Stripping html - dos, don'ts and more" (2022); MDN Web Docs, "DOMParser: parseFromString() method".
All rights reserved