So sánh các template engine phổ biến: Blade, Twig, Smarty, JSP, Thymeleaf, Jinja2, EJS...

Khi làm web render phía server, dữ liệu lấy từ database cần được đưa vào trang HTML trước khi gửi về trình duyệt. Template engine làm việc đó. Bạn viết một file HTML, chừa sẵn chỗ cho dữ liệu, engine sẽ điền dữ liệu vào và trả về trang hoàn chỉnh.

Nguyễn Dương Thế Vĩ 08/10/2026 · 20 Views

Ngôn ngữ nào cũng có vài engine riêng. Cú pháp mỗi cái một kiểu, nhưng cách hoạt động thì na ná nhau. Trong bài này mình sẽ đi qua từng engine hay gặp, cái nào cũng có ví dụ làm cùng một trang để bạn tiện so sánh.


1. Template engine làm những gì?

Engine nào cũng lo 5 việc:

  1. In biến: hiển thị user.name.
  2. Escape HTML: đổi <script> thành &lt;script&gt; để chống XSS.
  3. Điều kiện, vòng lặp: if, foreach.
  4. Layout: các trang con dùng chung một khung (header, footer).
  5. Tái sử dụng: include, component, macro.

Hầu hết engine sẽ dịch template sang code thật (PHP, Java, Python...) ở lần chạy đầu, lưu lại, và các lần sau chạy luôn bản đã dịch. Nhờ vậy chúng chạy rất nhanh. Nhưng cũng vì vậy mà đôi khi bạn sửa template xong lại không thấy gì thay đổi: trang đang chạy bản cũ trong cache.

Ví dụ trong cả bài: hiện lời chào và danh sách bài viết, nếu chưa có bài nào thì báo "Chưa có bài viết".


2. PHP

Blade (Laravel)

Engine mặc định của Laravel, cú pháp gọn và gần với PHP nhất.

@extends('layouts.app')

@section('content')
    <h1>Xin chào {{ $user->name }}</h1>

    @forelse ($posts as $post)
        <li>{{ $post->title }}</li>
    @empty
        <p>Chưa có bài viết.</p>
    @endforelse
@endsection
  • {{ }} tự escape, còn {!! !!} in nguyên HTML.
  • Layout dùng @extends, @section, @yield. Component viết dạng <x-button>, nhận tham số qua @props.
  • Viết được PHP ngay trong template: $post->title, count($posts), hoặc cả đoạn @php ... @endphp.
  • File đã dịch nằm trong storage/framework/views. Muốn xóa cache thì chạy php artisan view:clear.

Ưu điểm: biết PHP là dùng được ngay, component rất mạnh, có sẵn trong Laravel. Nhược điểm: gắn liền với Laravel (dùng riêng được nhưng ít ai làm). Viết PHP thoải mái quá nên dễ nhét logic vào view.

Twig (Symfony, Drupal)

Dùng trong Symfony, Drupal 8 trở đi, Craft CMS. Cú pháp học theo Jinja2 bên Python.

{% extends 'base.html.twig' %}

{% block content %}
    <h1>Xin chào {{ user.name }}</h1>

    {% for post in posts %}
        <li>{{ post.title|upper }}</li>
    {% else %}
        <p>Chưa có bài viết.</p>
    {% endfor %}
{% endblock %}
  • {{ }} để in, {% %} để viết lệnh, {# #} để ghi chú.
  • Tự escape sẵn. Muốn in nguyên HTML thì thêm |raw.
  • Viết user.name là đủ, Twig tự tìm $user['name'], $user->name hoặc $user->getName().
  • Không cho viết PHP trong template. Twig còn có chế độ sandbox, dùng khi muốn cho người dùng tự sửa template mà vẫn an toàn.

Ưu điểm: view và logic tách bạch, nhiều filter dựng sẵn, an toàn. Nhược điểm: muốn thêm hàm mới phải viết extension bằng PHP.

Smarty (Vtiger, PrestaShop, WHMCS)

Smarty có từ đầu những năm 2000 và tới giờ vẫn còn được dùng nhiều. Bạn sẽ gặp nó trong các hệ thống lâu năm như Vtiger CRM, PrestaShop (phần giao diện cho khách) hay WHMCS.

{extends file="layout.tpl"}

{block name="content"}
    <h1>Xin chào {$user.name|escape}</h1>

    {foreach $posts as $post}
        <li>{$post.title|escape}</li>
    {foreachelse}
        <p>Chưa có bài viết.</p>
    {/foreach}
{/block}
  • Lệnh nằm trong cặp { }. Biến có $ ở đầu, mảng hay object đều truy cập bằng dấu chấm: $user.name.
  • Mặc định không tự escape. Bạn phải thêm |escape cho từng biến, hoặc bật $smarty->setEscapeHtml(true); khi đó chỗ nào cần in nguyên HTML thì thêm nofilter.
  • Vì { được dùng cho lệnh, CSS hay JS viết thẳng trong file .tpl sẽ bị lỗi. Phải bọc chúng trong {literal}...{/literal}.
  • File đã dịch nằm trong thư mục templates_c/. Sửa .tpl mà không thấy đổi thì xóa thư mục này đi.

Ưu điểm: ổn định, tài liệu nhiều, có sẵn trong nhiều phần mềm thương mại. Nhược điểm: cú pháp cũ, rất dễ quên escape, hay vướng dấu { khi viết CSS/JS. Dự án PHP mới bây giờ ít khi chọn Smarty.


3. Java

JSP (Jakarta Server Pages)

Cách làm view lâu đời nhất của Java web, chạy trên các Servlet container như Tomcat.

<%@ taglib prefix="c" uri="jakarta.tags.core" %>

<h1>Xin chào <c:out value="${user.name}"/></h1>

<c:forEach var="post" items="${posts}">
    <li><c:out value="${post.title}"/></li>
</c:forEach>

<c:if test="${empty posts}">
    <p>Chưa có bài viết.</p>
</c:if>
  • ${...} là Expression Language (EL), và EL không escape: ${user.name} được in thẳng ra HTML. Phải dùng <c:out> hoặc fn:escapeXml().
  • Các thẻ JSTL như <c:if>, <c:forEach> dùng thay cho việc viết Java trực tiếp.
  • Vẫn viết được Java bằng scriptlet <% %>, nhưng cách này đã cũ, đừng dùng.
  • Mỗi file JSP được dịch thành một class Servlet.
  • JSP không có sẵn kế thừa layout, chỉ có <jsp:include>. Muốn có layout thật thì phải thêm Apache Tiles hoặc SiteMesh.

Ưu điểm: Servlet container nào cũng chạy được, rất nhiều hệ thống doanh nghiệp cũ vẫn đang dùng. Nhược điểm: dễ quên escape, khó viết test. Spring Boot cũng không khuyến khích, vì JSP bị hạn chế khi đóng gói ứng dụng thành file jar để chạy. Dự án mới thường dùng Thymeleaf.

Thymeleaf (Spring Boot)

Hiện là engine được dùng nhiều nhất với Spring Boot. Template của Thymeleaf vẫn là một file HTML hợp lệ, nên mở thẳng bằng trình duyệt vẫn xem được giao diện.

<h1 th:text="'Xin chào ' + ${user.name}">Xin chào Khách</h1>

<ul th:if="${not #lists.isEmpty(posts)}">
    <li th:each="post : ${posts}" th:text="${post.title}">Tiêu đề mẫu</li>
</ul>

<p th:unless="${not #lists.isEmpty(posts)}">Chưa có bài viết.</p>
  • Logic được viết trong các thuộc tính th:*. Chữ mẫu như "Xin chào Khách" sẽ được thay bằng dữ liệu thật khi chạy.
  • th:text tự escape, th:utext in nguyên HTML.
  • Tái sử dụng bằng fragment (th:fragment, th:replace). Muốn kế thừa layout thì cài thêm Layout Dialect.

Ưu điểm: designer mở file HTML là xem được ngay, đi rất ăn ý với Spring. Nhược điểm: cú pháp dài dòng, chạy chậm hơn các engine được dịch sẵn thành bytecode.

FreeMarker

Engine Java độc lập của Apache. Ngoài làm web, nó còn được dùng để tạo nội dung email, file cấu hình, hay sinh code.

<h1>Xin chào ${user.name}</h1>

<#list posts as post>
    <li>${post.title}</li>
<#else>
    <p>Chưa có bài viết.</p>
</#list>
  • ${} để in, <#...> để viết lệnh, <@...> để gọi macro.
  • Chỉ tự escape khi bạn cấu hình output format là HTML (có từ bản 2.3.24). Không cấu hình thì không escape.

Ưu điểm: nhanh, ổn định, dùng được cho mọi loại văn bản chứ không riêng HTML. Nhược điểm: cú pháp <# hơi lạ, phải nhớ cấu hình escape.


4. Python

Jinja2 (Flask, Ansible)

Engine của Flask, và cũng là thứ Ansible dùng để tạo file cấu hình. Twig bên PHP gần như lấy nguyên cú pháp của Jinja2.

{% extends "base.html" %}

{% block content %}
    <h1>Xin chào {{ user.name }}</h1>

    {% for post in posts %}
        <li>{{ post.title | title }}</li>
    {% else %}
        <p>Chưa có bài viết.</p>
    {% endfor %}
{% endblock %}
  • {{ }} để in, {% %} để viết lệnh, {# #} để ghi chú. Có macro để tái sử dụng.
  • Bản thân Jinja2 mặc định không escape. Flask bật escape sẵn cho các file .html, .htm, .xml; còn dùng Jinja2 ở chỗ khác thì bạn phải tự bật autoescape. In nguyên HTML bằng |safe.

Ưu điểm: cú pháp gọn, nhanh, dùng được cả ngoài web. Nhược điểm: dễ quên bật autoescape khi dùng ngoài Flask.

Django Templates

Engine có sẵn trong Django. Cú pháp gần giống Jinja2, nhưng Django chủ ý không cho viết nhiều logic trong template.

{% extends "base.html" %}

{% block content %}
    <h1>Xin chào {{ user.name }}</h1>

    {% for post in posts %}
        <li>{{ post.title }}</li>
    {% empty %}
        <p>Chưa có bài viết.</p>
    {% endfor %}
{% endblock %}
  • Tự escape sẵn, in nguyên HTML bằng |safe.
  • Không gọi được hàm có tham số trong template. Cần xử lý gì thì viết custom tag hoặc filter bằng Python.
  • Nếu thích, bạn có thể cấu hình Django dùng Jinja2 thay thế.

Ưu điểm: an toàn, buộc bạn để logic ở ngoài view. Nhược điểm: đôi khi bị bó tay, chạy chậm hơn Jinja2.


5. JavaScript (Node.js)

EJS (Express)

EJS là viết tắt của Embedded JavaScript: HTML xen lẫn JavaScript thật. Người mới học Express thường dùng EJS.

<h1>Xin chào <%= user.name %></h1>

<% if (posts.length) { %>
    <% posts.forEach(post => { %>
        <li><%= post.title %></li>
    <% }) %>
<% } else { %>
    <p>Chưa có bài viết.</p>
<% } %>
  • <%= %> có escape, <%- %> in nguyên HTML, <% %> để chạy code.
  • Include bằng <%- include('header') %>. Không có sẵn kế thừa layout, phải cài thêm thư viện như express-ejs-layouts.

Ưu điểm: biết JavaScript là dùng được, gần như không phải học gì thêm. Nhược điểm: toàn <% %> nên khó đọc, dễ nhét logic vào view. Chỉ khác nhau dấu = với dấu - mà một bên an toàn, một bên dính XSS.

Handlebars / Mustache

Hai engine này giữ template càng ít logic càng tốt, chủ yếu chỉ để in dữ liệu. Mustache có trước và có bản cho rất nhiều ngôn ngữ. Handlebars dựa trên Mustache và thêm helper.

<h1>Xin chào {{user.name}}</h1>

{{#each posts}}
    <li>{{title}}</li>
{{else}}
    <p>Chưa có bài viết.</p>
{{/each}}
  • {{ }} có escape, {{{ }}} in nguyên HTML.
  • Không viết được biểu thức như a > b trong template. Cần thì viết helper bằng JS.
  • Tái sử dụng bằng partial: {{> header}}.

Ưu điểm: template sạch, cùng một template chạy được ở cả server lẫn trình duyệt. Nhược điểm: việc đơn giản cũng phải viết helper.

Pug (tên cũ là Jade)

Pug không dùng thẻ HTML mà dựa vào thụt lề, giống Python.

extends layout

block content
  h1 Xin chào #{user.name}

  if posts.length
    each post in posts
      li= post.title
  else
    p Chưa có bài viết.
  • #{} và = có escape, !{} và != in nguyên HTML.
  • Có extends/block, include và mixin.

Ưu điểm: code rất ngắn, không bao giờ quên đóng thẻ. Nhược điểm: lệch một khoảng trắng là sai cấu trúc, khó dán HTML designer gửi vào.


6. Các ngôn ngữ khác

Razor (ASP.NET, C#)

@model HomeViewModel

<h1>Xin chào @Model.User.Name</h1>

@foreach (var post in Model.Posts)
{
    <li>@post.Title</li>
}
  • Gặp dấu @ là chuyển sang C#, Razor tự biết đoạn code kết thúc ở đâu. Có escape sẵn, in nguyên HTML bằng @Html.Raw().
  • Layout dùng file _Layout.cshtml với @RenderBody() và @RenderSection().
  • Blade mượn khá nhiều thứ từ Razor, như dấu @ và @section.

ERB (Ruby on Rails)

<h1>Xin chào <%= @user.name %></h1>

<% @posts.each do |post| %>
  <li><%= post.title %></li>
<% end %>
  • Cú pháp giống EJS, vì EJS học theo ERB. Trong Rails, <%= %> có escape sẵn; in nguyên HTML bằng raw hoặc html_safe.

Go html/template

<h1>Xin chào {{.User.Name}}</h1>

{{range .Posts}}
    <li>{{.Title}}</li>
{{else}}
    <p>Chưa có bài viết.</p>
{{end}}
  • Có sẵn trong thư viện chuẩn của Go.
  • Escape theo vị trí: engine biết biến đang nằm trong nội dung HTML, trong thuộc tính, trong URL hay trong JavaScript để escape cho phù hợp. Trong các engine ở bài này, đây là cách escape kỹ nhất.

7. Bảng so sánh

Engine Ngôn ngữ Hay gặp ở In biến Tự escape? In nguyên HTML Layout
Blade PHP Laravel {{ $x }} Có {!! $x !!} @extends
Twig PHP Symfony, Drupal {{ x }} Có |raw {% extends %}
Smarty PHP Vtiger, PrestaShop, WHMCS {$x} Không (mặc định) nofilter {extends}
JSP Java Tomcat, Spring MVC cũ ${x} Không ${x} Tiles, SiteMesh
Thymeleaf Java Spring Boot th:text Có th:utext Layout Dialect
FreeMarker Java Email, sinh code ${x} Tùy cấu hình ?no_esc <#import>
Jinja2 Python Flask, Ansible {{ x }} Flask: có |safe {% extends %}
Django Python Django {{ x }} Có |safe {% extends %}
EJS JS Express <%= x %> Có <%- x %> Thư viện ngoài
Handlebars JS Express, Ember {{x}} Có {{{x}}} Partial
Pug JS Express #{x} Có !{x} extends
Razor C# ASP.NET @x Có @Html.Raw(x) _Layout
ERB Ruby Rails <%= x %> Có (Rails) raw x yield
html/template Go Thư viện chuẩn {{.X}} Có, theo vị trí template.HTML {{template}}

Nhìn bảng, cú pháp chia ra khoảng ba kiểu:

  • Kiểu {{ }} / {% %}: Jinja2, Twig, Django, Handlebars, Go.
  • Kiểu <% %>: EJS, ERB, scriptlet của JSP.
  • Mỗi cái một kiểu: Blade và Razor dùng @, Thymeleaf dùng thuộc tính th:*, Pug dùng thụt lề, Smarty dùng {$}.

Đọc quen một engine thì nhìn các engine cùng kiểu cũng hiểu được phần lớn.


8. Nên chọn engine nào?

Đa số trường hợp bạn không cần chọn, vì framework đã có sẵn:

  • Laravel đi với Blade, Symfony với Twig, Spring Boot với Thymeleaf, Flask với Jinja2, Django với Django Templates, ASP.NET với Razor.
  • Nếu đang bảo trì Vtiger, PrestaShop hay WHMCS thì bắt buộc phải dùng Smarty.
  • Với Express: EJS nếu muốn đơn giản, Handlebars nếu muốn template sạch, Pug nếu thích viết ngắn.
  • Tạo nội dung email, file cấu hình, sinh code: FreeMarker (Java) hoặc Jinja2 (Python).

Còn nếu làm SPA bằng React, Vue hay Angular thì giao diện được dựng ở trình duyệt, server chỉ trả JSON. Khi đó các engine trên thường chỉ còn dùng cho email hoặc vài trang tĩnh.


9. Những lỗi hay gặp khi chuyển qua lại giữa các engine

1. Quên escape (lỗi XSS). Đây là lỗi nguy hiểm nhất. Quen với Blade hay Twig, nơi {{ }} tự escape, rồi sang Smarty, JSP hay FreeMarker cứ viết {$x} / ${x} mà không escape:

{* Sai: nếu comment có chứa <script> thì đoạn script đó sẽ chạy trên máy người xem *}
<p>{$comment}</p>

{* Đúng *}
<p>{$comment|escape}</p>

Chỉ in nguyên HTML ({!! !!}, |raw, |safe, th:utext, <%- %>) với nội dung do chính bạn viết, đừng bao giờ dùng với dữ liệu người dùng nhập vào.

2. Đụng dấu ngoặc với JavaScript.

  • Smarty: dấu { trong CSS/JS bị hiểu nhầm là lệnh, phải bọc trong {literal}.
  • Blade, Twig và Jinja2 dùng {{ }} giống Vue và Alpine. Trong Blade, viết @{{ name }} thì Blade sẽ bỏ qua để Vue xử lý.

3. Sửa template mà trang không đổi. Do engine vẫn đang chạy bản cũ trong cache:

  • Blade: php artisan view:clear.
  • Smarty: xóa thư mục templates_c/.
  • Twig: xóa thư mục cache, hoặc bật auto_reload lúc phát triển.
  • JSP: khởi động lại Tomcat hoặc xóa thư mục work/.

4. Nhét logic vào view. Query database, tính toán phức tạp, gọi API ngay trong template. Engine càng cho viết code thoải mái (Blade, EJS, scriptlet JSP) thì càng dễ mắc lỗi này. Nên chuẩn bị sẵn dữ liệu ở controller hoặc service, template chỉ việc hiển thị.

5. Nhầm cách truy cập dữ liệu.

Engine Thuộc tính Phần tử mảng
Blade $user->name $arr['key']
Smarty $user.name hoặc $user->name $arr.key
Twig, Jinja2 user.name arr.key hoặc arr['key']
JSP EL user.name arr['key']

Kết

Engine nào cũng xoay quanh 5 việc: in biến, escape, điều kiện và vòng lặp, layout, tái sử dụng. Chỗ khác nhau chủ yếu là cú pháp, và việc engine đó có tự escape hay không.

Lần đầu gặp một engine lạ, bạn chỉ cần tra 4 thứ: cách in biến, cách in nguyên HTML, cách viết vòng lặp, cách kế thừa layout. Biết 4 thứ này là đọc được phần lớn template của engine đó.