2026 前後端開發技術統整

轉眼間來到了 2026 年了,自己再過去開發到工作所開發的時間也來到了 4 年多,過去主要專注在網頁前後端開發,近期因為工作需求又以移動端 (React Native) 開發居多,中間也對於這些開發工具及架構有了不少轉換,再加上近期靠 AI 寫 code 的次數也越來越頻繁,並對他的架構有一定的需求跟規範,目的是讓自己人看得懂餅也有辦法做維護,所以打算整理一篇關於過去到現在所使用到的開發工具及架構的整理。

CChia1104
文章24 分鐘閱讀

過去 2024 有分享過當初整理自己個人部落格所用到的工具,有的仍在持續使用有部分近期也做了些轉變。

專案結構及開發工具的選擇

首先要講的就是 Monorepo,過去在第一份工作試著導入的工具目前自己開發仍一直在使用,尤其是自己同時做前後端開發真的是幫助了自己減少不少時間在管理不同的應用及彼此會共用到的內部套件。

Turborepo (Monorepo)

Turborepo,自己現在依然使用 Turborepo 做 monorepo 的管理,過去也嘗試過 Nx,但有三個主因讓自己仍選擇 Turborepo 做管理

turborepo

  1. 使用生態系統標準:Turborepo 建構於 JavaScript 套件管理器的 workspace 之上,相較於 Nx 使用大量插件和專屬程式碼,Turborepo 更輕量且標準化

  2. 更大的原始碼控制權:Nx 會用插件和抽象層包裝你的程式碼,而 Turborepo 讓你自己掌控工具配置

  3. 更少的配置需求:Turborepo 會自動推斷儲存庫需求,相較於 Nx 需要較少的配置檔案

整體而言 Turborepo 帶給我的體驗還是較直觀跟快速,也減少了自己花時間看文檔。

Vite 的生態系

還記得以前寫 React 多半還在 Create React App,公司內部較舊的專案也是如此,當初還要自己優化 webpack 的設定,而且當年用 CRA 的時候還記得若是專案規模一大,本地每次做個修改一 commit 最長還有等到 20 30 秒網頁才有所變化 = =,本地記憶體使用就更不提了。

如今 Vite 已普及很久了,之前也幫自己公司的幾個專案做 CRA 到 Vite 的轉換,這轉換在本地開發跟 CI/CD 的過程真的轉變很大,並且現在 Vite 的生態系也擴張到 Vite+,幫你把幾乎所有的開發工具集中管理在一起。

Vite 當中所用到的幾個工具自己現在也一起在使用中:

  • OXC - 是一套以 Rust 實作、專門服務 JavaScript / TypeScript 的高效能工具鏈與編譯器基礎建設,主打『整條鏈都最快』。
  • Rolldown - 是一個以 Rust 實作的新一代 JavaScript bundler,目標是成為 Vite 底層的統一打包工具,取代目前 Vite 同時依賴的 esbuild 與 Rollup。
  • Vitest - Vitest 是由 Vite 驅動的下一代測試框架,專為 Vite 專案設計,提供優異的開發體驗(DX)。

以前的專案都在使用 ESLintPrettier,這兩個也陪自己痕長一段時間了,不過今年自己也通一替換成 OXC 的 Oxlint 跟 Oxfmt,主因就是基於 JavaScript 所寫的 ESLint 跟 Prettier 在速度上真的有差,再加上自己的專案是 Monorepo 居多,有時本地的 ESLint server 很長會卡住,甚至還遇過 VSCode 閃退的問題,最後便選擇了 OXC,二來是也想通一一下自己的 build tools 盡量跟 Vite 對其。

後端開發

再來想先從後端開始講,由於自己在開發專案時多半是從後端的 DB 規劃再到 API 設計,最後才到前端設計,所以打算先以後端的方向先整理相關工具及架構設計。目前自己主要還是以 Node 做後端開發,後面還是會先環繞在 JavaScript 的生態系作說明。

整理近來開發後端最常遇到的幾個問題:

  • 環境變數管理
  • 資料庫的轉移設定
  • 針對佇列的架構設計

環境變數管理

在 JS 中我們都知道可以用 process.env 來獲取該系統的環境變數,但有個問題是:若我們在散亂的地方例如不同的 service 中獲取這些變數,我們其實很難統一管理,有的變數可能是系統必要的但沒有一個驗證的方式進而造成 runtime error,有時若是我們做了命名修改還會需要手動去搜尋對應的位置。

但若有一個可以統一管理跟驗證的套件做引用,不但可以方便管理更可以透過 TypeScript 做開發確認。

T3 Env

我這裡就要來介紹現在自己不管在開發前端還是後端都會用到的套件 T3 Env

什麼前端也有環境變數?這可聽到最後講前端的工具我會再細說。

T3 Env 是一個幫你在專案裡 定義、驗證、轉換 環境變數的工具,避免漏設或型別錯誤的 env 在 build/runtime 時才噴錯。

這個工具在做什麼
  • 讓你用 schema 定義環境變數,再由程式在啟動時自動驗證。
  • 驗證通過後,你在程式裡使用的 env 會是已型別安全、可帶預設值與轉換後的結果。
  • 型別安全 env:支援 TypeScript autocomplete、型別檢查,不再裸用 process.env.FOO
  • Standard Schema:只要是符合 Standard Schema 的 validator 都能用(Zod、Valibot、ArkType、Typia 等)。
  • Server / Client 區分:用 Proxy 技巧避免 server-only env 被 client bundle 或在 client 被誤用。
為什麼不用自己寫幾行 Zod 就好

文件 有舉一個常見的 Zod 例子:自己寫 envVariables.parse(process.env) 再把 type 宣告到 ProcessEnv 上。

import * as z from "zod";
 
const envVariables = z.object({
  DATABASE_URL: z.string(),
  CUSTOM_STUFF: z.string(),
});
 
envVariables.parse(process.env);
 
declare global {
  namespace NodeJS {
    interface ProcessEnv extends z.infer<typeof envVariables> {}
  }
}

這種 手工版 有幾個缺點:

  • 轉換與預設值:直接 parse process.env 不會修改原物件,所以型別會是轉換後型別,但實際值仍是原始字串,導致型別與實際值不一致,也無法好好處理 default。
  • 多環境支援:像 Next.js 會 treeshake 沒直接從 process.env 讀到的變數,簡單實作在某些 runtime 環境下會失效。
  • Client 驗證問題:把 server env 的 schema import 到 client 會直接錯誤,因為那些變數在 client 根本不存在。T3 Env 用 Proxy + safe parse 解這個坑。

資料庫的轉移設定

過去在寫後端 SQL 時為了避免 SQL Injection 跟可維護性可能會用上 ORM 這種工具,基於物件導向的思想去編寫 Class、Object、Method 等,生成 SQL 語句再往下去執行。

然而最大的問題依然回歸到了 效能問題,由於 ORM 在背後進行了大量的轉換與映射,因此相較於直接使用原生 SQL 語法來慢的不少。

但 ORM 仍有幾個優點

  • 可維護性:ORM 抽象了底層的 SQL 語法,讓開發人員專注於商業邏輯的開發
  • 移植性好:未來在做資料庫轉移時可減少不必要的修改
  • 安全性:由於 ORM 的結構設計可避免 SQL Injection 問題。

當然這些都是我們已知的優點,而其中有一個我覺得比較重要的就是 DB Migration。現今主流的 ORM 套件都提供了 Migration 功能,這不單單只是自動生成對應的 SQL,其中最重要的就是他多了版本控管。

drizzle migration

另外以 TypeORM 舉例,在 migration 中分別重要的 updown

import { MigrationInterface, QueryRunner } from "typeorm"

export class PostRefactoringTIMESTAMP implements MigrationInterface {
    async up(queryRunner: QueryRunner): Promise<void> {}

    async down(queryRunner: QueryRunner): Promise<void> {}
}
  • up - 寫 要做的 schema 變更,也就是升級 migration 時會執行的 SQL(例如改欄位名稱、加欄位、建立新 table)。
  • down - 寫 如何還原 up 做的事,用於 rollback last migration 時執行。

透過這類 ORM 工具的好處就是可以減少手寫 SQL 來運行 migration,其中我覺得若要提升效能也能用他們得 SQL Builder 寫法來減少 ORM 所帶來的影響。或是直接選 Kysely 這種型別安全的 SQL Builder 工具。

針對佇列的架構設計

先前曾有分享過解決 Vercel Workflow Devkit 的部署問題有提到這套件,這也是我現在在處理 RAG 及 webhook 很常會用到的套件之一。

Workflow SDK,一個讓任何 TypeScript/JavaScript 函式具備 耐久性 與高可靠性的 SDK,用來建構可暫停、恢復並持久保存狀態的工作流程與 AI Agent。

開發者不用自己建 message queue、retry 邏輯和持久層,只要寫一般的 async/await 商業邏輯即可,WDK 會負責排程、重試與狀態保存。

import { createWebhook, fetch } from "workflow";

export async function validatePaymentMethod(rideId: string) {
  "use workflow";

  const webhook = createWebhook();
  
  // Trigger external payment validation with callback to webhook URL
  await fetch("https://api.example-payments.com/validate-method", {
    method: "POST",
    body: JSON.stringify({ rideId, callback: webhook.url }),
  });

  // Wait for payment provider to confirm via webhook
  const { request } = await webhook;
  const confirmation = await request.json();

  return { rideId, status: confirmation.status };
}

World 抽象層

World 是把 workflow 跟底層基礎設施接起來的介面,負責三件事:Storage(儲存與事件)、Queue(排程與執行)、Streamer(即時資料流)。並實作一個 World,讓 workflows 可以跑在任何你自訂的基礎設施上。

其中自己最常用的就是 @workflow-worlds/redis,這是一個基於 Redis 跟 BullMQ 所實作的 World 抽象層。

vercel workflow inspect

除此之外 Workflow 還提供了可視化的 inspect 介面做觀測,讓這種佇列架構更好設計跟開發。

前端開發

最後想來整理關於前端開發的工具及技術架構,由於過往寫較多 React 跟 Vue,而這兩個框架比較不像 Angular 一樣有著明確的規範跟設計,所以自己從過往的開發經驗跟以前寫 NestJS 所學到的架構來做整理。並且以下會先以 React 的生態系做介紹。

React 開發必備的套件

在做 React 網站開發會用到的第三方套件不外乎就是狀態管理跟欄位驗證相關的套件,過去在開發內部系統的操作邏輯跟過去 Donkin.AI 的交易介面更是用到了非常多這些套件做整合:

React 的狀態管理模式

還記得過去學 React 的時候都是學 Redux 來做這些外部的狀態管理模式,並搭配 Redux-Sagaredux-thunk 來做這些非同步的 action 整理,並且針對這些非同步的 action 處理多半是呼叫 API,然而這種寫法會需要手寫一堆 thunk 和 reducer來處理 API 請求、快取與 loading 狀態管理。

這時 RTK Query 便解決了這問題

  • 幫你處理 資料抓取及快取 這整套流程,包含 loading 狀態、避免重複請求、樂觀更新、快取過期與更新等常見需求
  • 遠端資料抓取 視為和 一般 UI state 管理 不同的關注點,用專門工具處理資料請求,而不是全部塞進 Redux hand‑rolled 邏輯。
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { Pokemon } from './types'

// Define a service using a base URL and expected endpoints
export const pokemonApi = createApi({
  reducerPath: 'pokemonApi',
  baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
  endpoints: (build) => ({
    getPokemonByName: build.query<Pokemon, string>({
      query: (name) => `pokemon/${name}`,
    }),
  }),
})

// Export hooks for usage in functional components, which are
// auto-generated based on the defined endpoints
export const { useGetPokemonByNameQuery } = pokemonApi

然而 RTK Query 主要還是專注在 API 請求,但有時我們只是需要做單純 非同步 的狀態管理,例如:引用 window.navigator 這種非同步狀態,這時 RTK Query 就會顯得比較尷尬因為他主要是用 createApi + fetchBaseQuery 來做 API 請求的。

這時 @tanstack/react-query 就可以解決這問題:

const useQueryGEOLocation = () => {
  return useQuery({
    queryKey: ["geolocation"],
    queryFn: () => {
      return window.navigator.geolocation.getCurrentPosition(
        (position) => {
          return position.coords;
        },
        (error) => {
          throw error;
        }
      );
    },
  });
};

現在對於非同步的狀態管理我們可以用 react-query 做了,而現在的一般狀態管理回去看 Redux (RTK) 的寫法

我們同樣還是用 reducers 去做 同步 的狀態變更

import { createSlice, configureStore } from '@reduxjs/toolkit'

const countSlice = createSlice({
  name: 'count',
  initialState: { value: 0 },
  reducers: {
    incremented: (state, qty: number) => {
      // Redux Toolkit does not mutate the state, it uses the Immer library
      // behind scenes, allowing us to have something called "draft state".
      state.value += qty
    },
    decremented: (state, qty: number) => {
      state.value -= qty
    },
  },
})

const countStore = configureStore({ reducer: countSlice.reducer })

這裡來看看 Zustand,這也是我近期在用的狀態管理套件,其實寫法跟 Redux 差不多,但他是可以同時寫 非同步的方法

import { create } from 'zustand'

interface BearData {
  count: number
}

interface BearState {
  bears: number
  fetchBears: () => Promise<void>
}

export const useBearStore = create<BearState>()((set) => ({
  bears: 0,
  fetchBears: async () => { 
    const res = await fetch('/api/bears') 
    const data: BearData = await res.json() 
    set({ bears: data.count }) 
  }, 
}))

過去曾有分享過自己用 Zustand 整理 AI 聊天訊息的 store 套件,當中為了要處理 SSE (Server-Sent Events) 跟多組訊息架構,這裡用 React Query 就會顯得不好管理,我們始終還是需要這種門做狀態管理的套件來幫助整理的。

React 如何做資料格式驗證

再來說一下資料格式驗證這部分,這裡可以分兩種來說:一種是針對 API 請求時的回應驗證; 另一個則是我們發送請求的表單格式驗證。

API 請求時的回應驗證

在處理 API 請求的回應時,可以用到像是 Zod 這類的資料驗證庫。這些工具可以讓我們在接收到伺服器回應後,對返回的資料格式進行驗證,確保資料符合預期,從而避免因為格式錯誤而造成的程式問題。例如,若我們期望回覆中有特定欄位或數據類型,則可使用 Zod 定義相應的 schema 來進行檢查。這不僅提升了程式的堅韌度,還能有效減少潛在的錯誤發生。

除此之外我們還可以在這座格式轉換,例如:把後端回的時間轉成 Date 格式,另外這裡之所以用 pipe 命名其實是從 NestJS Pipes 那邊學來的,他就是專門做格式驗證及轉換用的。

user.pipe.ts
import dayjs from "dayjs";
import * as z from "zod";

export const UserDetail = z
  .object({
    id: z.string(),
    name: z.string(),
    createdAt: z.string(),
  })
  .transform((value) => ({
    ...value,
    createdAt: dayjs(value.createdAt).toDate(),
  }));

export type UserDetail = z.infer<typeof UserDetail>;

再來就可以透過 resource 來呼叫 API,而這裡之所以叫 resource 其實也是從 Angular 來的,雖然寫法比較不一樣但主要是專門寫 API 請求的方法。

user.resource.ts
import ky from "ky";

import { UserDetail } from "../pipes/user.pipe";

export const getUserDetail = async (
  id: string,
  options?: BaseQueryOptions
) => {
  const response = await ky.get(`/api/v1/user/${id}`, options).json();
  const result = UserDetail.parse(response);
  return result;
};
發送請求的表單格式驗證

最後就是表單的格式驗證,透過 React Hook Form 等工具,我們能夠輕鬆實現表單的驗證與管理。這些工具支援簡單的 API 和彈性的驗證規則,讓我們能夠在用戶提交表單前進行所需的檢查,而不需要過多的樣板代碼。使用時,我們可以定義規則,並與來自後端的返回結果相結合,確保數據的完整性和正確性。這樣的方式不僅提升了用戶體驗,也能減少伺服器的負擔,讓整體應用變得更加高效。

前端的環境變數及 Config 管理機制

大家在開發時過去應該都看過 VITE_NEXT_PUBLIC_ 等開頭的 環境變數,這幾個雖說是環境變數但講更確切一點他是你專案在建置階段時吃的環境變數,意思就是當你今天在你本地 bundle 你的專案那他吃的就是你電腦的環境變數,反之若你在線上例如 Cloud Build 建置你的專案那這環境變數就是吃你線上的環境變數,並且這些變數是會透過 Bundler hard code 寫在前端的 code 中的,所以前端的環境變數都只是靜態的常數而已。

然而大家在做專案開發時一定都遇過部署環境的拆分,例如上線前的 staging 測試用的 test 及正式的 production,而剛剛說的這些 常數 (環境變數)我們在專案建置前就要被寫到環境中

Dockerfile
FROM node:24-slim AS base

ENV NODE_ENV=production
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV TURBO_TELEMETRY_DISABLED=1
ENV SKIP_ENV_VALIDATION=1
ENV PORT=8080
ENV HOSTNAME=0.0.0.0

WORKDIR /app

RUN apt-get update && \
    apt-get upgrade -y && \
    apt-get install -y --no-install-recommends \
    python3 make g++ && \
    npm install -g corepack@latest && \
    corepack enable pnpm && \
    rm -rf /var/lib/apt/lists/*

FROM base AS installer

COPY . .
RUN pnpm install --frozen-lockfile --prefer-offline

FROM installer AS builder_staging

RUN cp .env.staging .env.production
RUN NODE_OPTIONS="--max-old-space-size=6144" pnpm build

FROM base AS runner

USER node

COPY --from=builder_staging --chown=node:node /app/package.json ./staging/
COPY --from=builder_staging --chown=node:node /app/public ./staging/public
COPY --from=builder_staging --chown=node:node /app/.next/standalone ./staging/
COPY --from=builder_staging --chown=node:node /app/.next/static ./staging/.next/static
RUN rm -rf ./staging/.env ./staging/.env.*

COPY --from=installer --chown=root:root --chmod=755 /app/builds/docker/docker-entrypoint.sh /docker-entrypoint.sh

CMD ["/docker-entrypoint.sh"]

但這種寫法就要避免在 .env 的檔案中寫了叫敏感的資訊,因為 docker 這裡會 紀錄在建置歷史與 cache 裡的,也能被 dump 出來看這些變數。

但如果你是一般的 SPA 並且又不想讓 docker 有記錄的話還有另一種方式:在 Runtime 的 layer 指定前端要讀的 config 檔,這裡我們同樣可以用前面所提的 @t3-oss/env-core 做整合

  1. 一開始先定義 env.ts,而這裡的 window.Config 就是我們晚點要分別定義的 常數
env.ts
import { createEnv } from '@t3-oss/env-core';
import * as z from 'zod';

export const env = createEnv({
	client: z.object({
    VITE_APP_SITE_URL: z.string().min(1),
    VITE_APP_APP_ENV: z.enum(['development', 'local', 'production', 'staging', 'test']),
    VITE_APP_APP_VERSION: z.string().min(1),
    VITE_APP_SENTRY_DSN: z.string().optional(),
    VITE_APP_GTM_ID: z.string().optional(),
    VITE_APP_GTM_AUTH: z.string().optional(),
    VITE_APP_GTM_PREVIEW: z.string().optional(),
    VITE_APP_GA_ID: z.string().optional(),
    VITE_APP_DEFAULT_TIME_ZONE: z.string().min(1),
    VITE_APP_DEFAULT_LOCALE: z.enum(Locale),
    VITE_APP_BACKEND_ENDPOINT: z.string().min(1),
  }),

	runtimeEnv: window.Config, 

	emptyStringAsUndefined: true,

	clientPrefix: 'VITE_APP_',
});
  1. 針對不同環境寫對應的 config
config.staging.js
// @ts-check
window.Config = {
	VITE_APP_SITE_URL: 'http://localhost:3000',
	VITE_APP_APP_ENV: 'staging',
	VITE_APP_APP_VERSION: 'develop',
	VITE_APP_DEFAULT_TIME_ZONE: 'Asia/Taipei',
	VITE_APP_DEFAULT_LOCALE: 'zh-TW',
	VITE_APP_BACKEND_ENDPOINT: 'https://example.com',
};
  1. 這時我們需要在 index.html 中做引入,這裡直接寫 config.js 就行了
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <link rel="icon" href="/favicon.ico" />
  <meta name="theme-color" content="#000000" />
  <meta name="description" content="" />
  <link rel="apple-touch-icon" href="/logo192.png" />
  <link rel="manifest" href="/manifest.json" />
  <title></title>
</head>

<body>
  <div id="app" class="h-screen w-screen"></div>
  <script src="/config.js"></script> 
  <script type="module" src="/src/main.tsx"></script>
</body>

</html>
  1. 最後就是在 Docker image 啟動時指定對應的 config
Dockerfile
ARG NODE_VERSION=24

FROM --platform=linux/amd64 node:${NODE_VERSION}-alpine AS base

ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"

WORKDIR /usr/src/app

RUN apk update && \
    apk add --no-cache \
    libc6-compat \
    git && \
    corepack enable

FROM base AS build
COPY . .

RUN pnpm install
RUN pnpm build

FROM --platform=linux/amd64 nginx:mainline-alpine

ENV CSP_FRAME_ANCESTORS_HOSTS=http://127.0.0.1

COPY --chown=root:root --chmod=0755 ./scripts/cicd/docker/30-config-mover.sh /docker-entrypoint.d/30-config-mover.sh
COPY --chown=root:root ./scripts/cicd/docker/nginx.conf /etc/nginx/nginx.conf
COPY --chown=root:root ./scripts/cicd/docker/templates/ /etc/nginx/templates
COPY --chown=nginx:nginx --from=build /usr/src/app/dist/ /usr/share/nginx/dist/

這裡的 APP_ENV 就是最後在運行 image 需要給的 runtime env 了

30-config-mover.sh
#!/usr/bin/env sh

export APP_ENV="${APP_ENV:-beta}"
export APP_IMAGE_VERSION="${APP_IMAGE_VERSION:-develop}"
export CONFIG_SOURCE_FILE="/usr/share/nginx/dist/config.${APP_ENV}.js"
export CONFIG_DEST_FILE="/usr/share/nginx/dist/config.js"
export ROBOTS_TXT_SOURCE_FILE="/usr/share/nginx/dist/robots.${APP_ENV}.txt"
export ROBOTS_TXT_DEST_FILE="/usr/share/nginx/dist/robots.txt"

cd /usr/share/nginx/dist || exit 1

if [ -f "${CONFIG_SOURCE_FILE}" ]; then
    /bin/cp -f "${CONFIG_SOURCE_FILE}" "${CONFIG_DEST_FILE}"
    /bin/sed -i "s/APP_IMAGE_VERSION/${APP_IMAGE_VERSION}/g" "${CONFIG_DEST_FILE}"
    chown nginx:nginx "${CONFIG_DEST_FILE}"
fi

if [ -f "${ROBOTS_TXT_SOURCE_FILE}" ]; then
    /bin/cp -f "${ROBOTS_TXT_SOURCE_FILE}" "${ROBOTS_TXT_DEST_FILE}"
    chown nginx:nginx "${ROBOTS_TXT_DEST_FILE}"
fi

Written by: Chia1104 CC BY-NC-SA 4.0

Chia1104
©
Chia1104