ฉัน Implement Business Web UI เดียวกันด้วย Vanilla HTML / Vue / React / Thymeleaf แล้วเปรียบเทียบ — ความแตกต่างของ 4 Stack และวิธีเลือก
สิ่งที่จะเรียนรู้จากบทความนี้
- โค้ดต่างกันอย่างไรจริงๆ เมื่อ business web UI spec เดียวกัน ถูก implement ใน 4 stack — Vanilla HTML / Vue 3 / React / Thymeleaf (พร้อมตัวอย่างโค้ด)
- ความแตกต่างของโครงสร้างโปรเจกต์และ build (package.json / Vite / pom.xml / plain HTML)
- เปรียบเทียบแบบเคียงข้างกัน ของ API client, state management, การจัดการฟอร์ม, การแสดง error, และ deployment
- framework การตัดสินใจว่า “จะเลือก stack ไหนในสถานการณ์แบบไหน”
เหมาะสำหรับใคร
- ใครก็ตามที่ไม่แน่ใจว่าจะใช้ตัวไหนใน Vanilla / Vue / React / Thymeleaf ตอนเลือก framework
- ใครก็ตามที่เคยใช้แต่ละตัวมาแล้ว แต่อยากรู้ว่าอะไรต่างกันจริงๆ เมื่อเทียบกับ spec เดียวกัน
- ใครก็ตามที่อยากเปรียบเทียบ stack โดยคำนึงถึง business web UI (form + API call + result display)
Environment
| รายการ | Version |
|---|---|
| Vanilla HTML | ไม่ต้อง build (module script) |
| Vue | 3.x + Vite 5.x + TypeScript 5.x + Vue Router 4.x |
| React | 18.x + Vite 5.x + TypeScript 5.x + React Router 6.x |
| Thymeleaf | Spring Boot 3.x + Java 21 + Thymeleaf 3.x |
1. บทนำ
พูดตรงๆ ตอนแรกไม่ได้ตั้งใจจะเขียนบทความเปรียบเทียบ
ระหว่างพัฒนา backend ของ banklink-service ฉันอยากได้ “หน้าจอที่เช็คได้ว่า API ทำงานอยู่หรือเปล่า” จึงประกอบขึ้นมาทันทีด้วย Vanilla HTML ซึ่งเป็นวิธีที่เร็วที่สุด เป็น UI แบบใช้แล้วทิ้งที่เป็นภาพของ “แค่ทำงานได้ก็พอ”
ระหว่างพัฒนาต่อ แผนของฉันคือ “สักวันจะเปลี่ยนเป็น Vue หรือ React จริงจัง แล้วทำเป็น development template แบบเต็มรูปแบบ” ตั้งใจจะเริ่ม implementation จริงของ frontend จากตรงนั้น
แต่พอลงมือทำจริงๆ ก็รู้ตัวขึ้นมา
ด้วยปริมาณ processing ขนาดนี้ (form + API call + แสดงผลลัพธ์) การ implement ทั้ง 4 stack รวม Thymeleaf ด้วยก็ไม่ได้ใช้เวลานานขนาดนั้น แถมถ้าทำให้ฟังก์ชันเหมือนกันหมด จะกลายเป็นหัวข้อที่ดีสำหรับเปรียบเทียบความแตกต่างของแต่ละเทคโนโลยีเคียงข้างกันไหมนะ?
ในแง่ความคืบหน้าของงานพัฒนา นี่คือ การออกนอกเส้นทาง โดยสมบูรณ์ ก่อนรู้ตัว แผน “สร้าง template หนึ่งตัว” ก็กลายเป็น “implement ขนานกัน 4 stack แล้วเปรียบเทียบ” ไปเสียแล้ว
แต่พอเขียนออกมาจริงๆ ก็พบว่าตัวเองได้จัดระเบียบเกณฑ์การตัดสินใจเลือกเทคโนโลยี แถมยังดูจะเป็นประโยชน์กับคนที่กำลังลังเลในสถานการณ์เดียวกันด้วย จึงตัดสินใจเก็บไว้เป็นบทความ บทความนี้คือผลพลอยได้จาก “การออกนอกเส้นทางที่สนุก” นั้น
เอาล่ะ เข้าเรื่องหลัก
เมื่อสร้าง business web UI “Vanilla HTML / Vue / React / Thymeleaf ตัวไหนเหมาะสม” คือการตัดสินใจแรกๆ ที่ต้องเจอ แต่ละตัวถูกพูดถึงเยอะเวลาแยกกัน แต่บทความที่เอาโค้ดจริงของทั้ง 4 มาเทียบกันเคียงข้างกับspec เดียวกันนั้นมีน้อยอย่างน่าแปลกใจ
จึงใช้ banklink-service (โปรเจกต์ฝึกส่วนตัว — banking API wrapper service) implement business UI ด้วย spec เดียวกันเป๊ะๆ ขนานกัน 4 stack บทความนี้คือบันทึกของการเปรียบเทียบนั้น
🔗 บทความที่เกี่ยวข้อง: backend design ของ
banklink-service(requirements → basic design → detailed design → PoC, การตัดสินใจออกแบบทั้ง 5 domain, และ implementation ของ DDD / ArchUnit / financial-grade quality) เขียนไว้ในบทความแยกต่างหาก บทความนี้คือ การเปรียบเทียบ 4 stack ฝั่ง frontend ที่เชื่อมกับ backend นั้น → การสร้าง Banking API ด้วยการออกแบบที่มาก่อน: จากการกำหนดความต้องการ 5 โดเมนสู่การตรวจสอบ PoC และแผนงานสู่ Production
จุดยืนของบทความนี้ นี่ไม่ใช่บทความที่นำเสนอ “คำตอบที่ถูกต้องหนึ่งเดียว” เป้าหมายคือให้ข้อมูลสำหรับตัดสินว่า stack ไหนเหมาะกับโปรเจกต์ของคุณ โดยเปรียบเทียบโค้ดที่ implement requirement เดียวกันในทั้ง 4 stack
2. Spec ร่วม (ทำให้เหมือนกันทั้งหมดใน 4 stack)
ทั้ง 4 implementation ตรงตาม spec ต่อไปนี้
ฟีเจอร์
- 6 หน้า: Home / บัญชี / สินเชื่อ / เงินตราต่างประเทศ / การลงทุน / KYC
- แต่ละหน้ามีหลาย section สำหรับ API operation (เช่น หน้าบัญชีมี 5 section: “รายการ, ดูยอด, ฝาก, ถอน, ประวัติธุรกรรม”)
- ช่องกรอก Bearer Token ที่ด้านบนหน้าจอตั้งค่า API auth token
- Navigation ให้เปลี่ยนหน้าไปมาได้
- กดปุ่มแล้วเรียก API และแสดงผลลัพธ์บนหน้าจอ
Backend ที่เชื่อมต่อ
- API Spring Boot เดียวกัน (
/api/v1/accounts,/api/v1/loans, …) - Response เป็น JSON, error รวมเป็นโครงสร้าง HTTP status + body เดียวกัน
สิ่งที่ไม่ได้ทำให้เหมือนกัน (จุดที่แตกต่าง)
- รูปลักษณ์ CSS (ตั้งใจให้ต่างกันระหว่าง external UI กับ internal UI)
- Internal implementation (ตามธรรมเนียมของแต่ละ framework)
พูดอีกแบบคือ ฉันสร้างฐานเปรียบเทียบที่ “หน้าจอและฟังก์ชันเหมือนกัน ต่างกันแค่ภายในของทั้ง 4 เวอร์ชัน”

หน้าบัญชี (ด้านบน) มี 5 section: “ดึงรายการบัญชี, ดูยอด, ฝาก, ถอน, ประวัติธุรกรรม” หน้าจอและฟังก์ชันเดียวกันนี้ที่ implement แยกกันใน 4 stack คือสิ่งที่บทความนี้เปรียบเทียบ
3. ภาพรวมโครงสร้างของ 4 Stack
ก่อนอื่นมาดู “โครงสร้างขั้นต่ำ” ของแต่ละตัว
Vanilla HTML
banklink-web-vanilla-html/
├─ index.html ← หน้าบนสุด
├─ accounts.html ← หน้าบัญชี
├─ loans.html ← หน้าสินเชื่อ
├─ ... (อีก 4 หน้า)
├─ common-external.js ← จัดการ token + binding ร่วม
└─ shared/
├─ api/client.js ← fetch wrapper
└─ common.js ← bindAction / renderResponse
ไม่มี build tool เปิด .html ตรงๆ ใน browser ได้เลย (หรือ serve ด้วย nginx) import JS ด้วย <script type="module">
Vue
banklink-web-vue/
├─ vite.config.ts
├─ index.html ← SPA entry
└─ src/
├─ main.ts ← createApp + mount
├─ App.vue ← layout + RouterView
├─ router/index.ts ← Vue Router config
├─ api/client.ts ← fetch wrapper (TypeScript)
└─ views/
├─ HomeView.vue
├─ AccountsView.vue ← หน้าบัญชี
├─ ... (อีก 4 หน้า)
npm run build สร้าง static file เข้า dist/ → serve ด้วย nginx
React
banklink-web-react/
├─ vite.config.ts
├─ index.html
└─ src/
├─ main.tsx ← createRoot + render
├─ App.tsx ← รวมทั้ง 6 หน้าไว้ไฟล์เดียว (เพราะเล็ก)
└─ ... (shared/api/client.ts)
คล้าย Vue แต่รวมทุกหน้าไว้ใน App.tsx (ลด component count ให้น้อยที่สุด)
Thymeleaf
banklink-web-thymeleaf/
├─ pom.xml
└─ banklink-external-web-thymeleaf/
└─ src/main/
├─ java/com/y104autumn/banklink/external/
│ ├─ BanklinkExternalApplication.java
│ ├─ controller/
│ │ ├─ ExternalTopPageController.java
│ │ ├─ AccountsController.java
│ │ └─ ... (อีก 4 หน้า)
│ ├─ service/
│ │ ├─ AccountsService.java ← เรียก API ด้วย RestClient
│ │ └─ ...
│ └─ form/
│ ├─ AccountsForm.java ← สำหรับ @ModelAttribute
│ └─ ...
└─ resources/templates/
├─ index.html
├─ accounts.html ← มี th:field
└─ ...
mvn package สร้าง jar ที่ execute ได้ → เริ่มด้วย java -jar หรือ Docker
เปรียบเทียบจำนวนไฟล์ Config
| ทั้งโปรเจกต์ | ไฟล์ที่จำเป็นสำหรับ 1 หน้า | |
|---|---|---|
| Vanilla HTML | ประมาณ 10 ไฟล์ | 1 (accounts.html เท่านั้น) |
| Vue | ประมาณ 15 ไฟล์ | 1 (AccountsView.vue) |
| React | ประมาณ 5 ไฟล์ | 1 (function ใน App.tsx) |
| Thymeleaf | ประมาณ 25 ไฟล์ | 3 (Controller + Service + Form + template) |
Thymeleaf มี boilerplate สำหรับแบ่ง MVC เป็น 3 layer เยอะ จำนวนไฟล์มากที่สุด แต่บทบาทของแต่ละไฟล์ชัดเจน
6 หน้าที่สร้าง (ภาพหน้าจออ้างอิง)
5 หน้าที่เหลือนอกจากหน้าบัญชี มีฟังก์ชันเดียวกันทั้ง 4 stack เพื่อเป็นข้อมูลอ้างอิง นี่คือหน้าจอของเวอร์ชัน Vanilla HTML





ความแตกต่างของรูปลักษณ์ External UI กับ Internal UI
ฉันใช้นโยบาย “ฟังก์ชันเหมือนกัน แต่ตั้งใจให้รูปลักษณ์ต่างกันระหว่าง external UI กับ internal UI” internal UI (ใกล้เคียง staff terminal) มีรูปแบบนี้

การเปรียบเทียบในบทความนี้ใช้ external UI เป็นหัวข้อ แต่แต่ละ stack implement ทั้ง external/internal สองชุด โดยโครงสร้างทำให้สลับแค่ CSS ก็รองรับได้ทั้งสองแบบ
4. ความแตกต่างของโครงสร้างโปรเจกต์และ Build
Vanilla HTML — ไม่มี build config
ไม่มีทั้ง package.json และ tsconfig.json แค่เปิดไฟล์ตรงๆ ใน browser หรือ serve แบบ static ด้วย nginx
# Development: เปิดตรงๆ ใน browser
open accounts.html
# Production: วางไว้ที่ document root ของ nginx
nginx -c nginx-external.conf
ไม่มี dependency package, ไม่มี build process, ไม่มี node_modules
Vue — Vite + TypeScript
// package.json (ส่วนหลัก)
{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"typescript": "^5.5.0",
"vite": "^5.3.0",
"vue-tsc": "^2.0.0"
}
}
npm install # สร้าง node_modules
npm run dev # dev server แบบ hot-reload ที่ http://localhost:5173
npm run build # สร้าง static file เข้า dist/
React — Vite + TypeScript (Vite เดียวกับ Vue)
{
"scripts": {
"dev": "vite",
"build": "tsc && vite build"
},
"dependencies": {
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^6.24.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.5.0",
"vite": "^5.3.0"
}
}
ความรู้สึกในการใช้งานเกือบเหมือน Vue ต่างกันแค่ @vitejs/plugin-vue กับ @vitejs/plugin-react
Thymeleaf — Maven + Spring Boot
<!-- pom.xml (ส่วนหลัก) -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.5</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
mvn clean package # สร้าง target/*.jar
java -jar target/banklink-external-web-thymeleaf.jar # เริ่มทำงาน
ต้องมี JVM jar ที่สร้างใน target/ มี Tomcat ฝังอยู่ด้วย จึงไม่ต้องมี application server เพิ่ม
ความรู้สึกเรื่องเวลา Build
| Stack | install ครั้งแรก ฯลฯ | Production build | เวลาเริ่มทำงาน |
|---|---|---|---|
| Vanilla HTML | 0 วินาที | 0 วินาที | ทันที |
| Vue | 30–60 วินาที (npm install) | 5–15 วินาที | ตามเวลาที่ nginx เริ่ม |
| React | 30–60 วินาที | 5–15 วินาที | ตามเวลาที่ nginx เริ่ม |
| Thymeleaf | 30–120 วินาที (download dependency ของ Maven) | 20–40 วินาที (mvn package) | JVM startup (5–15 วินาที) |
5. Implement หน้าจอเดียวกัน (หน้าบัญชี) ใน 4 Stack
นี่คือหัวใจของบทความนี้ มาดูโค้ดที่ implement 5 section เดียวกันเป๊ะๆ — ดึงรายการบัญชี, ดูยอด, ฝาก, ถอน, ประวัติธุรกรรม — ที่เขียนต่างกัน 4 แบบ
เวอร์ชัน Vanilla HTML
HTML กับ JS อยู่ใน accounts.html เดียวกัน (เป็น module ผ่าน <script type="module">)
<!-- accounts.html (ส่วนที่ตัดมา: รายการบัญชีและส่วนฝาก) -->
<section class="section-card">
<h2>ดึงรายการบัญชี</h2>
<button id="accounts-list-btn">GET /api/v1/accounts</button>
<div id="accounts-list-response"></div>
</section>
<section class="section-card">
<h2>ฝาก</h2>
<label>accountId<input id="deposit-account-id" value="ACC-0001" /></label>
<label>amount<input id="deposit-amount" type="number" value="10000" /></label>
<label>Idempotency-Key<input id="deposit-key" value="dep-key-001" /></label>
<button id="accounts-deposit-btn">POST /api/v1/accounts/{id}/deposit</button>
<div id="accounts-deposit-response"></div>
</section>
<script type="module">
import { bindAction, numberValue, value } from "./common-external.js";
// map id ปุ่มกับ id ของที่แสดง response
bindAction("accounts-list-btn", "accounts-list-response", () => ({
method: "GET",
path: "/api/v1/accounts",
}));
bindAction("accounts-deposit-btn", "accounts-deposit-response", () => ({
method: "POST",
path: `/api/v1/accounts/${value("deposit-account-id")}/deposit`,
idempotencyKey: value("deposit-key"),
body: {
amount: numberValue("deposit-amount"),
currency: value("deposit-currency"),
reference: value("deposit-reference"),
},
}));
</script>
ลักษณะเด่น:
- element HTML ระบุด้วย
id→ อ้างอิงจาก JS ผ่านdocument.getElementById(ทำใน functionbindAction) - ค่า input อ่านตามจังหวะด้วย helper
value("input-id")(ไม่ reactive — อ่าน “ค่า ณ ตอนที่คลิก”) - ผลลัพธ์แสดงเป็น string ผ่าน
innerHTML
เวอร์ชัน Vue
<!-- AccountsView.vue (script setup + template) -->
<script setup lang="ts">
import { inject, ref } from "vue";
import type { Ref } from "vue";
import { requestApi } from "../api/client";
const token = inject<Ref<string>>("token")!;
function fmt(r: unknown) { return JSON.stringify(r, null, 2); }
// รายการบัญชี
const listRes = ref<string>("");
const listStatus = ref<number | null>(null);
async function getAccounts() {
const r = await requestApi({ method: "GET", path: "/api/v1/accounts", token: token.value });
listStatus.value = r.status; listRes.value = fmt(r.body);
}
// ฝาก
const depId = ref("ACC-0001"), depAmount = ref(10000),
depKey = ref("dep-key-001"), depCurrency = ref("JPY"), depRef = ref("TEST-DEP-001");
const depRes = ref(""); const depStatus = ref<number | null>(null);
async function deposit() {
const r = await requestApi({
method: "POST",
path: `/api/v1/accounts/${depId.value}/deposit`,
token: token.value,
idempotencyKey: depKey.value,
body: { amount: depAmount.value, currency: depCurrency.value, reference: depRef.value },
});
depStatus.value = r.status; depRes.value = fmt(r.body);
}
</script>
<template>
<section class="section-card">
<h2>ดึงรายการบัญชี</h2>
<button @click="getAccounts">GET /api/v1/accounts</button>
<pre v-if="listStatus !== null">HTTP {{ listStatus }}\n{{ listRes }}</pre>
</section>
<section class="section-card">
<h2>ฝาก</h2>
<label>accountId<input v-model="depId" /></label>
<label>amount<input v-model.number="depAmount" type="number" /></label>
<label>Idempotency-Key<input v-model="depKey" /></label>
<button @click="deposit">POST /api/v1/accounts/{id}/deposit</button>
<pre v-if="depStatus !== null">HTTP {{ depStatus }}\n{{ depRes }}</pre>
</section>
</template>
ลักษณะเด่น:
- ประกาศตัวแปร reactiveด้วย
ref()bind สองทางด้วยv-model - รับ token ที่ใช้ร่วมกันจาก parent ด้วย
inject<Ref<string>>("token") - แยกเงื่อนไขแสดง response ด้วย
v-ifฝังตัวแปรด้วย{{ }}
เวอร์ชัน React
// function AccountsPage ใน App.tsx (ส่วนที่ตัดมา)
function AccountsPage({ token }: PageProps) {
const [listResponse, setListResponse] = useState<ApiResult | null>(null);
const [depositResponse, setDepositResponse] = useState<ApiResult | null>(null);
const [depositAccountId, setDepositAccountId] = useState("ACC-0001");
const [depositAmount, setDepositAmount] = useState("10000");
const [depositKey, setDepositKey] = useState("dep-key-001");
const [depositCurrency, setDepositCurrency] = useState("JPY");
const [depositReference, setDepositReference] = useState("TEST-DEP-001");
return (
<div className="page-grid">
<Section title="ดึงรายการบัญชี">
<button
onClick={async () =>
setListResponse(await requestApi({ method: "GET", path: "/api/v1/accounts", token }))
}
>
GET /api/v1/accounts
</button>
<ResponsePanel response={listResponse} />
</Section>
<Section title="ฝาก">
<label>accountId
<input value={depositAccountId} onChange={e => setDepositAccountId(e.target.value)} />
</label>
<label>amount
<input type="number" value={depositAmount} onChange={e => setDepositAmount(e.target.value)} />
</label>
<label>Idempotency-Key
<input value={depositKey} onChange={e => setDepositKey(e.target.value)} />
</label>
<button
onClick={async () =>
setDepositResponse(await requestApi({
method: "POST",
path: `/api/v1/accounts/${depositAccountId}/deposit`,
token,
idempotencyKey: depositKey,
body: {
amount: Number(depositAmount),
currency: depositCurrency,
reference: depositReference,
},
}))
}
>
POST /api/v1/accounts/{"{id}"}/deposit
</button>
<ResponsePanel response={depositResponse} />
</Section>
</div>
);
}
ลักษณะเด่น:
- ประกาศ
useStateสำหรับทุก input field และทุก response (ประกาศเยอะกว่าrefของ Vue) - ต้องเขียน handler
onChange={e => setX(e.target.value)}เองทุก input (ไม่มี sugar syntax แบบv-modelของ Vue) - เขียน async handler ของปุ่มตรงๆ ใน
JSXได้เลย
เวอร์ชัน Thymeleaf
โครงสร้าง 3 ชั้น: template + controller + service + form object
<!-- accounts.html (ส่วนที่ตัดมา: รายการบัญชีและส่วนฝาก) -->
<section class="section-card">
<h2>ดึงรายการบัญชี (Form ฝั่ง server)</h2>
<form id="accounts-form" th:action="@{/api/v1/accounts}" th:object="${accountsForm}" method="post">
<input type="hidden" th:field="*{authorization}" />
<button type="submit" class="action-button">POST /api/v1/accounts</button>
</form>
<div th:if="${accountsForm != null and accountsForm.apiResponse != null}" class="response-box">
<p><strong>Status:</strong> <span th:text="${accountsForm.apiResponse.statusCode}">0</span></p>
<pre th:text="${accountsForm.apiResponse.body}"></pre>
</div>
</section>
<section class="section-card">
<h2>ฝาก</h2>
<form th:action="@{/api/v1/accounts/deposit}" th:object="${accountsForm}" method="post">
<input type="hidden" th:field="*{authorization}" />
<label>accountId<input th:field="*{depositAccountId}" /></label>
<label>amount<input th:field="*{depositAmount}" type="number" /></label>
<label>Idempotency-Key<input th:field="*{depositIdempotencyKey}" /></label>
<button type="submit" class="action-button">POST /api/v1/accounts/deposit</button>
</form>
</section>
// AccountsController.java
@Controller
@RequiredArgsConstructor
public class AccountsController {
private final AccountsService accountsService;
@PostMapping("/api/v1/accounts")
public String listAccounts(
@ModelAttribute("accountsForm") AccountsForm form,
Model model) {
applyToken(form.getAuthorization());
form.setApiResponse(accountsService.listAccounts());
model.addAttribute("accountsForm", form);
return "accounts"; // render accounts.html ใหม่
}
@PostMapping("/api/v1/accounts/deposit")
public String deposit(
@ModelAttribute("accountsForm") AccountsForm form,
Model model) {
applyToken(form.getAuthorization());
Map<String, Object> body = Map.of(
"amount", form.getDepositAmount(),
"currency", form.getDepositCurrency(),
"reference", form.getDepositReference()
);
form.setApiResponse(accountsService.deposit(
form.getDepositAccountId(), body, form.getDepositIdempotencyKey()));
model.addAttribute("accountsForm", form);
return "accounts";
}
}
// AccountsForm.java (ส่วนที่ตัดมา)
@Data
public class AccountsForm {
private String authorization;
private String depositAccountId;
private Integer depositAmount;
private String depositCurrency;
private String depositReference;
private String depositIdempotencyKey;
private ExternalApiResponse apiResponse;
// ... (field อื่นๆ)
}
ลักษณะเด่น:
- แต่ละ operation ตาม web flow แบบดั้งเดิม HTTP POST → server ประมวลผล → render หน้าใหม่
th:field="*{depositAmount}"bind สองทางระหว่าง input กับ field ของ Form Object (Spring ส่งค่าให้อัตโนมัติทั้งสองทาง)- ผลลัพธ์ก็render เป็น HTML ฝั่ง server → JS ฝั่ง client มีน้อยที่สุด
เปรียบเทียบจำนวนบรรทัด (หน้าบัญชีทั้งหมด)
| จำนวนบรรทัดโค้ด | ภาษา / ไฟล์ | |
|---|---|---|
| Vanilla HTML | ประมาณ 130 บรรทัด | HTML + JS (1 ไฟล์) |
| Vue | ประมาณ 130 บรรทัด | TypeScript + Template (1 ไฟล์) |
| React | ประมาณ 350 บรรทัด | TypeScript JSX (1 function) |
| Thymeleaf | ประมาณ 250 บรรทัด | HTML + Java (กระจาย 5 ไฟล์) |
เหตุผลที่ React ยาวมาก:
useStatedeclaration และonChangehandler ของแต่ละ input field, async callback ของแต่ละปุ่ม ต้องเขียนแยกทีละอัน เพราะไม่มี sugar syntax แบบv-modelของ Vue ปริมาณโค้ดจึงมีแนวโน้มเพิ่มขึ้น
6. ความแตกต่างของ API Client
ทั้งหมดเรียก backend API เดียวกัน (/api/v1/accounts เป็นต้น) แต่ client implementation ต่างกันเล็กน้อย
Vanilla HTML / Vue / React — fetch ของ JavaScript
// client.ts เวอร์ชัน Vue
export interface ApiResult {
status: number;
body: unknown;
}
export async function requestApi(opts: {
method: string;
path: string;
token: string;
idempotencyKey?: string;
body?: unknown;
}): Promise<ApiResult> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${opts.token}`,
};
if (opts.idempotencyKey) {
headers["Idempotency-Key"] = opts.idempotencyKey;
}
try {
const res = await fetch(opts.path, {
method: opts.method,
headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
});
const text = await res.text();
const responseBody = text ? JSON.parse(text) : null;
return { status: res.status, body: responseBody };
} catch (e) {
return { status: 0, body: String(e) };
}
}
Vanilla / Vue / React แทบเหมือนกันเป๊ะ ต่างกันแค่มี type annotation หรือไม่ (Vanilla เป็น .js, Vue / React เป็น .ts)
Thymeleaf — RestClient ของ Spring Boot
// AccountsService.java
@Service
@RequiredArgsConstructor
public class AccountsService {
private final RestClient restClient;
private String token;
public ExternalApiResponse listAccounts() {
ResponseEntity<String> response = restClient
.get()
.uri("/api/v1/accounts")
.header("Authorization", "Bearer " + token)
.retrieve()
.toEntity(String.class);
return new ExternalApiResponse(
response.getStatusCode().value(),
true,
response.getBody()
);
}
}
RestClient ของ Java (Spring 6.1+) แบบ builder API เมื่อเทียบกับ fetch type เข้มงวดกว่า และ IDE completion ทำงานได้ดีกว่า
จุดร่วมและจุดต่าง
| ประเด็น | Vanilla/Vue/React | Thymeleaf |
|---|---|---|
| ภาษา | JavaScript / TypeScript | Java |
| HTTP client | fetch (มาตรฐาน) | RestClient (มาตรฐานของ Spring) |
| การจัดการ error | try/catch + status code | try/catch + HttpClientErrorException |
| Type safety | type annotation ของ TypeScript | type-safe โดย default ใน Java |
| จุดที่เกิด network call | Browser → API | Server → API |
แถวสุดท้าย “จุดที่เกิด network call” คือความแตกต่างเชิง architecture ที่สำคัญที่สุด
- สาย SPA (Vanilla/Vue/React) เรียก API ตรงจาก browser → ต้องตั้งค่า CORS / credential ของ API ไปถึง client
- Thymeleaf ให้ server เรียก API → ไม่ต้อง CORS / credential อยู่แค่ในฝั่ง server
7. ความแตกต่างของ State Management และ Data Binding
วิธีที่แต่ละ stack เก็บ “ค่า input บนหน้าจอ, response ที่ได้มา, token” คือจุดที่เอกลักษณ์ของแต่ละ stack แสดงออกมา
Vanilla HTML — DOM คือแหล่งที่มาของ state
// ค่า input: ดึงจาก DOM เสมอ
const accountId = document.getElementById("balance-account-id").value;
// แสดง response: เขียน string ตรงๆ ลงใน innerHTML
document.getElementById("balance-response").innerHTML = formatResponse(result);
// token: บันทึกลง localStorage
localStorage.setItem("banklink_token", token);
ไม่มีแนวคิด “state” ต้องไปอ่านค่าจาก DOM หรือ localStorage เสมอ เรียบง่ายแต่ sync ยากขึ้นเมื่อแอปใหญ่ขึ้น
Vue — ประกาศแบบ reactive ด้วย ref
const balanceId = ref("ACC-0001"); // ตัวแปร reactive ที่เก็บค่า string
const balanceRes = ref(""); // เขียน {{ balanceRes }} ใน template แล้วจะ update อัตโนมัติ
// ฝั่ง template เขียน <input v-model="balanceId" />
// input ของ user จะสะท้อนเข้า balanceId.value อัตโนมัติ (two-way binding)
model คือ “ประกาศตัวแปร reactive → template ตามอัตโนมัติ” ผู้เขียนไม่ต้องคิดเรื่อง sync state
React — ประกาศ state hook ด้วย useState
const [balanceId, setBalanceId] = useState("ACC-0001");
const [balanceRes, setBalanceRes] = useState<ApiResult | null>(null);
// ฝั่ง template: ส่ง value กับ handler แยกกัน
<input value={balanceId} onChange={e => setBalanceId(e.target.value)} />
model คือ “ประกาศคู่ variable/setter → update ผ่าน setter → re-render” ไม่มี sugar syntax แบบ v-model ของ Vue จึงต้องเขียน onChange ให้ทุก input field ทำให้ปริมาณโค้ดเพิ่มขึ้น
Thymeleaf — Form Object รวม state ไว้ฝั่ง server
@Data
public class AccountsForm {
private String authorization;
private String balanceAccountId;
private ExternalApiResponse apiResponse;
// ... field อื่นๆ
}
<input th:field="*{balanceAccountId}" />
Form Object ฝั่ง server คือ “แหล่งความจริงเดียว” ของ state ทุก POST จะใส่ค่า input ลง Form Object, Controller ประมวลผล → ผลลัพธ์ถูกใส่กลับเข้า Form Object แล้ว render ใหม่ เป็น web model แบบดั้งเดิมที่ “ไม่เก็บ state ฝั่ง client”
เหมาะกับสถานการณ์ไหน
| ลักษณะแอป | Stack ที่เหมาะ |
|---|---|
| เล็ก, หน้าเดียว, แทบไม่มี state | Vanilla HTML |
| ใช้ reactive UI เยอะ, form เยอะ | Vue |
| reuse component เยอะ, ให้ความสำคัญกับ ecosystem | React |
| อยากให้ state อยู่ฝั่ง server (แบบ business system) | Thymeleaf |
8. ความแตกต่างของการจัดการฟอร์ม
วิธี implement ฟอร์มฝาก (5 field: accountId / amount / currency / reference / Idempotency-Key)
Vanilla HTML
// ใส่ id ให้แต่ละ input
<input id="deposit-account-id" value="ACC-0001" />
// อ่านค่าทั้งหมดพร้อมกันตอนคลิก
const body = {
accountId: document.getElementById("deposit-account-id").value,
amount: Number(document.getElementById("deposit-amount").value),
// ...
};
เขียน helper function value() ลดการเขียนซ้ำได้ แต่แนวคิด “form” มีอยู่แค่ฝั่ง HTML/JS เท่านั้น
Vue — เสร็จใน 5 บรรทัดด้วย v-model
<label>accountId<input v-model="depId" /></label>
<label>amount<input v-model.number="depAmount" type="number" /></label>
<label>currency<input v-model="depCurrency" /></label>
<label>reference<input v-model="depRef" /></label>
<label>Idempotency-Key<input v-model="depKey" /></label>
v-model.number แปลงเป็น numeric type อัตโนมัติ เขียนน้อยที่สุด
React — useState + onChange ต่อ field
const [depositAccountId, setDepositAccountId] = useState("ACC-0001");
const [depositAmount, setDepositAmount] = useState("10000");
const [depositCurrency, setDepositCurrency] = useState("JPY");
// ... ทำแบบนี้ทุก field
<input value={depositAccountId} onChange={e => setDepositAccountId(e.target.value)} />
<input type="number" value={depositAmount} onChange={e => setDepositAmount(e.target.value)} />
// ...
5 field = useState × 5 + onChange × 5 library ภายนอกอย่าง react-hook-form ช่วยลดได้ แต่บทความนี้เปรียบเทียบ “React ล้วนๆ”
Thymeleaf — th:field bind อัตโนมัติกับ Form Object
<form th:action="@{/api/v1/accounts/deposit}" th:object="${accountsForm}" method="post">
<input type="hidden" th:field="*{authorization}" />
<label>accountId<input th:field="*{depositAccountId}" /></label>
<label>amount<input th:field="*{depositAmount}" type="number" /></label>
<label>Idempotency-Key<input th:field="*{depositIdempotencyKey}" /></label>
<button type="submit">ส่ง</button>
</form>
th:field แค่ตัวเดียว set attribute name, id, value อัตโนมัติ และผูก field เข้ากับ Form Object ฝั่ง server ความรู้สึกใกล้เคียงกับ v-model ของ Vue
9. ความแตกต่างของการแสดง Error และ Loading
รูปแบบการแสดงผลเมื่อ API call ล้มเหลว
Vanilla HTML
function renderResponse(targetId, response) {
const target = document.getElementById(targetId);
const badgeClass = response.status >= 200 && response.status < 300 ? "ok" : "err";
target.innerHTML = `
<div class="response-panel">
<span class="badge ${badgeClass}">HTTP ${response.status}</span>
<pre>${escapeHtml(JSON.stringify(response.body, null, 2))}</pre>
</div>
`;
}
ต้อง escape ด้วยมือ เขียน function escapeHtml เองหรือใช้ textContent
Vue / React — escape อัตโนมัติ + conditional rendering
<!-- Vue -->
<div v-if="status !== null" class="response-panel">
<span :class="status < 400 ? 'badge-ok' : 'badge-err'">HTTP {{ status }}</span>
<pre>{{ response }}</pre>
</div>
// React
{response && (
<div className="response-panel">
<span className={response.status < 400 ? "badge-ok" : "badge-err"}>HTTP {response.status}</span>
<pre>{JSON.stringify(response.body, null, 2)}</pre>
</div>
)}
ฝังค่าด้วย {{ }} หรือ {} จะถูกescape อัตโนมัติ ไม่ต้องคิดเรื่อง XSS
Thymeleaf — th:text escape อัตโนมัติ
<div th:if="${accountsForm.apiResponse != null}" class="response-box">
<p>Status: <span th:text="${accountsForm.apiResponse.statusCode}"></span></p>
<pre th:text="${accountsForm.apiResponse.body}"></pre>
</div>
th:text ก็ escape อัตโนมัติเช่นกัน ใช้ th:utext จะ unescape (มีความเสี่ยง XSS)
10. ความแตกต่างของการตั้งค่าเริ่มทำงานและ Deploy
Vanilla HTML — serve แบบ static ด้วย nginx
# nginx-external.conf
server {
listen 8080;
root /app/banklink-external-web-vanilla-html;
index index.html;
location /api/ {
proxy_pass http://banklink-api:8080; # reverse proxy ไปยัง API
}
}
FROM nginx:alpine
COPY banklink-web-vanilla-html /app/
COPY nginx-external.conf /etc/nginx/conf.d/default.conf
โครงสร้างขั้นต่ำ serve HTML/JS/CSS ตามเดิม
Vue / React — Vite build → serve ด้วย nginx
# build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build # สร้าง dist/
# serve stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx-external.conf /etc/nginx/conf.d/default.conf
ผล build ถูกวางไว้ที่ nginx ต่างจาก Vanilla แค่มี build stage เพิ่มขึ้นมา
Thymeleaf — jar ของ Spring Boot ที่ execute ได้
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests
FROM eclipse-temurin:21-jre-alpine
COPY --from=builder /app/target/*.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
ต้องมี JVM container หนักหลายสิบ MB บวก JVM (ประมาณ 200 MB)
เปรียบเทียบขนาด Image
| ขนาด container image | เวลาเริ่มทำงาน | |
|---|---|---|
| Vanilla HTML (nginx) | ~25 MB | < 1 วินาที |
| Vue (serve ด้วย nginx) | ~30 MB | < 1 วินาที |
| React (serve ด้วย nginx) | ~30 MB | < 1 วินาที |
| Thymeleaf (Spring Boot + JVM) | ~200 MB | 5–15 วินาที |
ถ้าให้ความสำคัญกับขนาดเล็ก กลุ่มที่ serve ด้วย nginx (3 ตัวแรก) ได้เปรียบ Spring Boot หนักกว่า แต่แลกมาด้วยความสามารถจัดการ server-side logic, API proxy, auth integration ในกระบวนการเดียวกัน
11. ตารางเปรียบเทียบเคียงข้างกัน
| ประเด็น | Vanilla HTML | Vue 3 | React | Thymeleaf |
|---|---|---|---|---|
| ภาษา | JS | TS | TS (TSX) | Java |
| Build | ไม่ต้อง | Vite | Vite | Maven |
| Learning cost | ต่ำ | กลาง | กลาง–สูง | กลาง (ต่ำถ้ารู้ Java อยู่แล้ว) |
| จำนวนบรรทัดโค้ด (หน้าบัญชี) | ~130 | ~130 | ~350 | ~250 (กระจาย) |
| State model | DOM | Reactive ref | useState | Form Object (server) |
| Form binding | Manual | v-model | onChange ต่อ field | th:field |
| XSS auto-escape | Manual | Automatic | Automatic | Automatic |
| จุดที่เรียก API | Browser | Browser | Browser | Server |
| ที่เก็บ credential | localStorage | localStorage | localStorage | Server session |
| ต้องใช้ CORS | ใช่ | ใช่ | ใช่ | ไม่ |
| จำนวน dependency package | 0 | ~10 | ~10 | ~20 (Maven) |
| Container image | ~25 MB | ~30 MB | ~30 MB | ~200 MB |
| เวลาเริ่มทำงาน | ทันที | ทันที | ทันที | 5–15 วินาที (JVM) |
| Dynamic UI | อ่อน | แข็งแรง | แข็งแรง | อ่อน (ต้อง reload) |
| Ecosystem | ไม่มี | ขนาดกลาง | ใหญ่มาก | Ecosystem ของ Spring |
12. จะเลือกอย่างไร — จุดแข็งจุดอ่อนและเกณฑ์ตัดสินใจของ 4 Stack
Vanilla HTML
จุดแข็ง:
- ไม่มี dependency, ไม่มี build, ไม่มี learning cost (แค่พื้นฐาน HTML/JS)
- ต้นทุนการ deliver ต่ำสุด, container image เล็กสุด
- ยอดเยี่ยมสำหรับ “สร้าง demo ที่ทำงานได้ใน 30 นาที”
จุดอ่อน:
- state management พังเมื่อแอปใหญ่ขึ้น (ข้อจำกัดของการจัดการ DOM ตรงๆ)
- ไม่มี type safety ของ TypeScript
- ไม่เหมาะกับ reactive UI
เลือกเมื่อ:
- เครื่องมือตรวจสอบ API, ฟอร์มทดสอบภายใน, dashboard ง่ายๆ
- “ไม่อยากได้ framework แค่อยากได้หน้าจอ”
- prototype ที่วางแผนจะเปลี่ยนเป็น SPA ทีหลัง
Vue
จุดแข็ง:
- sugar syntax อย่าง
v-modelทำให้โค้ดน้อยกว่า React - template syntax ใกล้เคียง HTML อ่านง่ายแม้สำหรับมือใหม่
- single-file component (.vue) รวม logic/template/style ไว้ไฟล์เดียว
จุดอ่อน:
- ecosystem เล็กกว่า React
- ตำแหน่งงานและคนหางานน้อยกว่า React
เลือกเมื่อ:
- business SPA ที่มีฟอร์มและ input UI เยอะ
- “ถ้าเลือก framework ไม่ถูก ลอง Vue ก่อน”
- โปรเจกต์ที่ตลาดแรงงาน web engineer อยู่ในประเทศเป็นหลัก
React
จุดแข็ง:
- ecosystem ใหญ่มาก (UI library, state management, testing, mobile)
- เข้ากันได้ดีกับ TypeScript (type definition ครบ)
- ตำแหน่งงานในตลาดมากที่สุด
จุดอ่อน:
- มักใช้บรรทัดมากกว่า Vue สำหรับฟังก์ชันเดียวกัน
- ต้นทุนการเรียนรู้การใช้
useState,useEffect,useMemo,useCallbackให้ถูกต้อง - ต้องเข้าใจการ re-render ของ function component
เลือกเมื่อ:
- SPA ขนาดใหญ่ หรือมองไปที่ Next.js / React Native
- องค์กรที่ให้ความสำคัญกับการจ้าง engineer
- อยากใช้ UI library ซ้ำ (MUI / Mantine / shadcn เป็นต้น)
Thymeleaf
จุดแข็ง:
- ลด client-side JS ให้น้อยที่สุดได้ (แบบ business system)
- credential และ API token อยู่แค่ในฝั่ง server (ได้เปรียบเมื่อ security requirement เข้มงวด)
- ใช้ ecosystem ของ Spring Security / Spring Boot เต็มที่
- Java engineer สร้าง web UI ได้ด้วยทักษะที่มีอยู่แล้ว
จุดอ่อน:
- ทุกการเปลี่ยนหน้าต้อง round trip ไป server (รู้สึกช้ากว่า SPA)
- ไม่เหมาะกับ reactive UI
- container image ของ JVM หนัก
เลือกเมื่อ:
- Business system / หน้าจอ admin ภายใน
- security requirement ไม่อยากเปิดเผย API key ให้ client
- องค์กรที่มี Java engineer เยอะ ใช้ Spring Boot อยู่แล้ว
- “ส่งฟอร์มแล้วแสดงผล” ก็เพียงพอ ไม่ต้องการ UI หรูหรา
Decision Flow
Q1. UI ต้อง reactive หรือไม่?
├─ ไม่ (แค่ส่งฟอร์มแล้วแสดงผลก็พอ)
│ ├─ อยากรวม state ฝั่ง server → Thymeleaf
│ └─ อยากให้เบา → Vanilla HTML
└─ ใช่ (ต้องการ reactive UI)
├─ ให้ความสำคัญกับ ecosystem/การจ้างงาน → React
└─ อยากลดโค้ด / learning cost ต่ำ → Vue
13. สรุปและสิ่งที่ได้เรียนรู้
- 4 stack ไม่ใช่ “ถูก vs ผิด” — ใครได้เปรียบเปลี่ยนไปตาม requirement ของโปรเจกต์ การเอา spec เดียวกันมาเรียงกันทำให้เห็นความแตกต่างชัดขึ้น
- ถ้าดูแค่จำนวนบรรทัดโค้ด Vanilla / Vue น้อยกว่า React มากกว่า แต่ React ก็ชดเชยด้วยตลาดแรงงานและ ecosystem
- Thymeleaf ไม่ได้ล้าสมัย — ยังเข้ากับ security และ operational requirement ของ business system ในปัจจุบันบ่อยครั้ง
- ข้อสรุปคือ: “เลือก framework โดยย้อนกลับจาก requirement” เลือกตามกระแสจะลำบากในอีกไม่กี่ปีข้างหน้า
- การรัน 4 implementation ขนานกันทำให้เห็นชัดว่า ความแตกต่างของ build tooling (Vite vs. Maven vs. ไม่มี) สร้างความแตกต่างมหาศาลต่อประสบการณ์ในวันแรกของโปรเจกต์ ในช่วง prototype ฝั่ง Vanilla / Vite เร็วกว่าอย่างเห็นได้ชัด
หวังว่าการเปรียบเทียบนี้จะเป็นข้อมูลชิ้นหนึ่งตอนที่คุณต้องตัดสินใจเลือก