轉眼間來到了 2026 年了,自己再過去開發到工作所開發的時間也來到了 4 年多,過去主要專注在網頁前後端開發,近期因為工作需求又以移動端 (React Native) 開發居多,中間也對於這些開發工具及架構有了不少轉換,再加上近期靠 AI 寫 code 的次數也越來越頻繁,並對他的架構有一定的需求跟規範,目的是讓自己人看得懂餅也有辦法做維護,所以打算整理一篇關於過去到現在所使用到的開發工具及架構的整理。
Written by: Chia1104 CC BY-NC-SA 4.0
過去 2024 有分享過當初整理自己個人部落格所用到的工具,有的仍在持續使用有部分近期也做了些轉變。
首先要講的就是 Monorepo,過去在第一份工作試著導入的工具目前自己開發仍一直在使用,尤其是自己同時做前後端開發真的是幫助了自己減少不少時間在管理不同的應用及彼此會共用到的內部套件。
Turborepo,自己現在依然使用 Turborepo 做 monorepo 的管理,過去也嘗試過 Nx,但有三個主因讓自己仍選擇 Turborepo 做管理

使用生態系統標準:Turborepo 建構於 JavaScript 套件管理器的 workspace 之上,相較於 Nx 使用大量插件和專屬程式碼,Turborepo 更輕量且標準化
更大的原始碼控制權:Nx 會用插件和抽象層包裝你的程式碼,而 Turborepo 讓你自己掌控工具配置
更少的配置需求:Turborepo 會自動推斷儲存庫需求,相較於 Nx 需要較少的配置檔案
整體而言 Turborepo 帶給我的體驗還是較直觀跟快速,也減少了自己花時間看文檔。
還記得以前寫 React 多半還在 Create React App,公司內部較舊的專案也是如此,當初還要自己優化 webpack 的設定,而且當年用 CRA 的時候還記得若是專案規模一大,本地每次做個修改一 commit 最長還有等到 20 30 秒網頁才有所變化 = =,本地記憶體使用就更不提了。
如今 Vite 已普及很久了,之前也幫自己公司的幾個專案做 CRA 到 Vite 的轉換,這轉換在本地開發跟 CI/CD 的過程真的轉變很大,並且現在 Vite 的生態系也擴張到 Vite+,幫你把幾乎所有的開發工具集中管理在一起。
Vite 當中所用到的幾個工具自己現在也一起在使用中:
再來想先從後端開始講,由於自己在開發專案時多半是從後端的 DB 規劃再到 API 設計,最後才到前端設計,所以打算先以後端的方向先整理相關工具及架構設計。目前自己主要還是以 Node 做後端開發,後面還是會先環繞在 JavaScript 的生態系作說明。
整理近來開發後端最常遇到的幾個問題:
在 JS 中我們都知道可以用 process.env 來獲取該系統的環境變數,但有個問題是:若我們在散亂的地方例如不同的 service 中獲取這些變數,我們其實很難統一管理,有的變數可能是系統必要的但沒有一個驗證的方式進而造成 runtime error,有時若是我們做了命名修改還會需要手動去搜尋對應的位置。
但若有一個可以統一管理跟驗證的套件做引用,不但可以方便管理更可以透過 TypeScript 做開發確認。
我這裡就要來介紹現在自己不管在開發前端還是後端都會用到的套件 T3 Env
什麼前端也有環境變數?這可聽到最後講前端的工具我會再細說。
T3 Env 是一個幫你在專案裡 定義、驗證、轉換 環境變數的工具,避免漏設或型別錯誤的 env 在 build/runtime 時才噴錯。
process.env.FOO文件 有舉一個常見的 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> {}
}
}這種 手工版 有幾個缺點:
process.env 不會修改原物件,所以型別會是轉換後型別,但實際值仍是原始字串,導致型別與實際值不一致,也無法好好處理 default。process.env 讀到的變數,簡單實作在某些 runtime 環境下會失效。過去在寫後端 SQL 時為了避免 SQL Injection 跟可維護性可能會用上 ORM 這種工具,基於物件導向的思想去編寫 Class、Object、Method 等,生成 SQL 語句再往下去執行。
然而最大的問題依然回歸到了 效能問題,由於 ORM 在背後進行了大量的轉換與映射,因此相較於直接使用原生 SQL 語法來慢的不少。
但 ORM 仍有幾個優點
當然這些都是我們已知的優點,而其中有一個我覺得比較重要的就是 DB Migration。現今主流的 ORM 套件都提供了 Migration 功能,這不單單只是自動生成對應的 SQL,其中最重要的就是他多了版本控管。

另外以 TypeORM 舉例,在 migration 中分別重要的 up 跟 down
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 是把 workflow 跟底層基礎設施接起來的介面,負責三件事:Storage(儲存與事件)、Queue(排程與執行)、Streamer(即時資料流)。並實作一個 World,讓 workflows 可以跑在任何你自訂的基礎設施上。
其中自己最常用的就是 @workflow-worlds/redis,這是一個基於 Redis 跟 BullMQ 所實作的 World 抽象層。

除此之外 Workflow 還提供了可視化的 inspect 介面做觀測,讓這種佇列架構更好設計跟開發。
最後想來整理關於前端開發的工具及技術架構,由於過往寫較多 React 跟 Vue,而這兩個框架比較不像 Angular 一樣有著明確的規範跟設計,所以自己從過往的開發經驗跟以前寫 NestJS 所學到的架構來做整理。並且以下會先以 React 的生態系做介紹。
在做 React 網站開發會用到的第三方套件不外乎就是狀態管理跟欄位驗證相關的套件,過去在開發內部系統的操作邏輯跟過去 Donkin.AI 的交易介面更是用到了非常多這些套件做整合:
還記得過去學 React 的時候都是學 Redux 來做這些外部的狀態管理模式,並搭配 Redux-Saga 或 redux-thunk 來做這些非同步的 action 整理,並且針對這些非同步的 action 處理多半是呼叫 API,然而這種寫法會需要手寫一堆 thunk 和 reducer來處理 API 請求、快取與 loading 狀態管理。
這時 RTK Query 便解決了這問題
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 就會顯得不好管理,我們始終還是需要這種門做狀態管理的套件來幫助整理的。
再來說一下資料格式驗證這部分,這裡可以分兩種來說:一種是針對 API 請求時的回應驗證; 另一個則是我們發送請求的表單格式驗證。
在處理 API 請求的回應時,可以用到像是 Zod 這類的資料驗證庫。這些工具可以讓我們在接收到伺服器回應後,對返回的資料格式進行驗證,確保資料符合預期,從而避免因為格式錯誤而造成的程式問題。例如,若我們期望回覆中有特定欄位或數據類型,則可使用 Zod 定義相應的 schema 來進行檢查。這不僅提升了程式的堅韌度,還能有效減少潛在的錯誤發生。
除此之外我們還可以在這座格式轉換,例如:把後端回的時間轉成 Date 格式,另外這裡之所以用 pipe 命名其實是從 NestJS Pipes 那邊學來的,他就是專門做格式驗證及轉換用的。
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 請求的方法。
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 和彈性的驗證規則,讓我們能夠在用戶提交表單前進行所需的檢查,而不需要過多的樣板代碼。使用時,我們可以定義規則,並與來自後端的返回結果相結合,確保數據的完整性和正確性。這樣的方式不僅提升了用戶體驗,也能減少伺服器的負擔,讓整體應用變得更加高效。
大家在開發時過去應該都看過 VITE_ 或 NEXT_PUBLIC_ 等開頭的 環境變數,這幾個雖說是環境變數但講更確切一點他是你專案在建置階段時吃的環境變數,意思就是當你今天在你本地 bundle 你的專案那他吃的就是你電腦的環境變數,反之若你在線上例如 Cloud Build 建置你的專案那這環境變數就是吃你線上的環境變數,並且這些變數是會透過 Bundler hard code 寫在前端的 code 中的,所以前端的環境變數都只是靜態的常數而已。
然而大家在做專案開發時一定都遇過部署環境的拆分,例如上線前的 staging 測試用的 test 及正式的 production,而剛剛說的這些 常數 (環境變數)我們在專案建置前就要被寫到環境中
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 做整合
env.ts,而這裡的 window.Config 就是我們晚點要分別定義的 常數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_',
});// @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',
};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>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 了
#!/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