ฉัน Implement Business Web UI เดียวกันด้วย Vanilla HTML / Vue / React / Thymeleaf แล้วเปรียบเทียบ — ความแตกต่างของ 4 Stack และวิธีเลือก

Vanilla HTML Vue3 React Thymeleaf Spring Boot TypeScript Java Frontend Comparison Library Selection

สิ่งที่จะเรียนรู้จากบทความนี้

  • โค้ดต่างกันอย่างไรจริงๆ เมื่อ 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)
Vue3.x + Vite 5.x + TypeScript 5.x + Vue Router 4.x
React18.x + Vite 5.x + TypeScript 5.x + React Router 6.x
ThymeleafSpring 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 เวอร์ชัน

หน้าจอ "บัญชี" ที่บทความนี้เจาะลึก — เวอร์ชัน external UI ของ Vanilla HTML ทั้ง 4 stack มีฟังก์ชันเดียวกัน

หน้าบัญชี (ด้านบน) มี 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) — กรอก token + navigation + ลิงก์ไปแต่ละหน้า

หน้าสินเชื่อ — API operation section เช่น สมัคร, สอบถามยอด, จำลองการชำระ

หน้าเงินตราต่างประเทศ — operation เช่น ดูอัตรา, ทำรายการแลกเปลี่ยน

หน้าการลงทุน — operation เช่น portfolio, คำสั่งซื้อ/ขาย

หน้า KYC — operation เช่น สถานะยืนยันตัวตน, ส่งเอกสาร

ความแตกต่างของรูปลักษณ์ External UI กับ Internal UI

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

หน้าบนสุด Internal UI (เวอร์ชัน Vanilla HTML) — ตั้งใจออกแบบให้ต่างออกไปให้ใกล้เคียง 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

Stackinstall ครั้งแรก ฯลฯProduction buildเวลาเริ่มทำงาน
Vanilla HTML0 วินาที0 วินาทีทันที
Vue30–60 วินาที (npm install)5–15 วินาทีตามเวลาที่ nginx เริ่ม
React30–60 วินาที5–15 วินาทีตามเวลาที่ nginx เริ่ม
Thymeleaf30–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 (ทำใน function bindAction)
  • ค่า 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 ยาวมาก: useState declaration และ onChange handler ของแต่ละ 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/ReactThymeleaf
ภาษาJavaScript / TypeScriptJava
HTTP clientfetch (มาตรฐาน)RestClient (มาตรฐานของ Spring)
การจัดการ errortry/catch + status codetry/catch + HttpClientErrorException
Type safetytype annotation ของ TypeScripttype-safe โดย default ใน Java
จุดที่เกิด network callBrowser → APIServer → 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 ที่เหมาะ
เล็ก, หน้าเดียว, แทบไม่มี stateVanilla HTML
ใช้ reactive UI เยอะ, form เยอะVue
reuse component เยอะ, ให้ความสำคัญกับ ecosystemReact
อยากให้ 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 MB5–15 วินาที

ถ้าให้ความสำคัญกับขนาดเล็ก กลุ่มที่ serve ด้วย nginx (3 ตัวแรก) ได้เปรียบ Spring Boot หนักกว่า แต่แลกมาด้วยความสามารถจัดการ server-side logic, API proxy, auth integration ในกระบวนการเดียวกัน


11. ตารางเปรียบเทียบเคียงข้างกัน

ประเด็นVanilla HTMLVue 3ReactThymeleaf
ภาษาJSTSTS (TSX)Java
Buildไม่ต้องViteViteMaven
Learning costต่ำกลางกลาง–สูงกลาง (ต่ำถ้ารู้ Java อยู่แล้ว)
จำนวนบรรทัดโค้ด (หน้าบัญชี)~130~130~350~250 (กระจาย)
State modelDOMReactive refuseStateForm Object (server)
Form bindingManualv-modelonChange ต่อ fieldth:field
XSS auto-escapeManualAutomaticAutomaticAutomatic
จุดที่เรียก APIBrowserBrowserBrowserServer
ที่เก็บ credentiallocalStoragelocalStoragelocalStorageServer session
ต้องใช้ CORSใช่ใช่ใช่ไม่
จำนวน dependency package0~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 เร็วกว่าอย่างเห็นได้ชัด

หวังว่าการเปรียบเทียบนี้จะเป็นข้อมูลชิ้นหนึ่งตอนที่คุณต้องตัดสินใจเลือก

ส่งข้อความได้ตามสบาย

ไม่ว่าจะเป็นการว่าจ้างงาน แนะนำโปรเจกต์ ความคิดเห็น หรือคำถาม ยินดีรับทั้งหมด ฉันหวังเป็นอย่างยิ่งว่าจะได้เชื่อมต่อกับผู้ที่มีอุดมการณ์อันสูงส่งเช่นเดียวกัน ฉันจะมุ่งมั่นท้าทายในสิ่งที่ทุ่มเททั้งชีวิตต่อไป ขอขอบคุณและฝากเนื้อฝากตัวด้วยครับ/ค่ะ