這裡有一個聽起來很簡單、直到你真的把 UI 上線之後才會發現答案的問題:要多少個檔案,團隊裡就再也沒有人——包括你自己——能自信地說出按下按鈕時會發生什麼事?

對我來說,答案大概是兩百個左右。不是因為邏輯很複雜,而是因為那兩百個檔案裡,沒有任何一個和其他檔案對「按鈕應該怎麼做」這件事有一致的共識。

這就是我一路走到那裡的故事、它讓我付出了什麼代價,以及我最後做出來、用來避免這件事再發生在我身上的引擎。LuciaCore 是一個原型,不是完成品框架,但它已經做到我原本想讓它做到的那件事:讓我可以把一個可運作、可互動的網頁當作資料來描述,而不是每次都親手組裝它。

我的出發點

我的背景是系統程式設計:作業系統、網路、CPU 排程、架構、API 效能。那是我很自在的層次,因為那裡的問題通常都有明確定義。你通常能夠說出瓶頸在哪裡、量測它,並且知道你的修正到底有沒有真的生效。

不過,再怎麼樣也不會永遠孤立地運作。到了某個時間點,不管做出來的是什麼,都需要一張臉:Web 應用、手機用戶端、桌面工具,某種人真的會去點的東西。所以我開始學前端開發,原本以為那會是回到有趣工作的前一段簡單繞路。HTML、CSS、JS 從概念上理解只花了一兩天。再加上幾天的教學和小練習,我就覺得自己準備好了。

我錯在「簡單」這件事上,但不是我原本以為的那種錯。問題從來不是語法。

一個成長中的前端專案,實際上要付出的代價

我先從作品集網站開始。紙面上看起來範圍不大。實際做起來卻花了整整一個月,而且那一個月幾乎沒有多少時間花在決定網站要說什麼、長什麼樣子上;大多都花在想法和可執行頁面之間的那些事。

第一筆稅是 CSS。每個按鈕的對齊都得手動決定。每新增一個 class,都要想一套命名規則,而且那還是我邊做邊發明出來的;每做一個新頁面,我都會想稍微偏離那套規則,而不是老老實實照著走。專案就是這樣悄悄長出三種不同寫法、功能上其實一樣的按鈕,而且沒有哪一種真的錯,只是彼此沒有協調好。

第二筆稅是檔案數量。為了整理方便,我把專案切成小模組,一開始是幾十個檔案,後來變成幾百個 HTML、CSS、JS 檔。這樣雖然改善了檔案層級的可讀性,卻在頁面層級造成載入問題,因為每個頁面都需要發出遠超過必要數量的獨立請求。於是我又把它們全部打包起來:一個 JS 檔、一個 CSS 檔、每個頁面幾個 HTML 檔。這解決了載入時間,卻悄悄讓下一個問題更嚴重。

大致上,這個變化長得像這樣:

Before(數百個小檔案)                    After(已打包)
portfolio/                                 portfolio/
├── header.html                            ├── index.html
├── header.css                             ├── about.html
├── header.js                              ├── contact.html
├── footer.html                            ├── bundle.js
├── footer.css                             └── bundle.css
├── footer.js
├── button-primary.html
├── button-primary.css
├── button-primary.js
├── button-secondary.html
├── button-secondary.css
├── button-secondary.js
└── ... 另外還有 190+ 個類似檔案

請求變少了,底層問題卻沒變:每次需要修改時,原本那些檔案還是得靠人工去找、打開、理解。

真正的成本是在維護時浮現的。每次 UI 調整或視覺 bug,都變成一次搜尋:一個檔案一個檔案地找,試圖定位到底是哪個檔案對畫面上的結果負責。沒有任何東西會明確地回指到它的來源,因為一開始就沒有用一致的方式去建構它。每個元件都只是反映我那天覺得「看起來應該是這樣」的想法,彼此之間沒有共享結構可以串起來。這部分很難跟沒經歷過的人解釋:不是單一檔案不好,而是你必須同時把整個專案累積下來的不一致都放在腦中,這件事本身已經變成一份全職工作。

差不多就在那個時候,我去看了 Tailwind、React 和 TypeScript,以為一個正規的框架應該能解決這個問題。結果它只是把問題移動了位置,沒有把它消掉。我還是需要 npm、建置流程和設定檔,而且我還是會得到幾百個檔案,只是從我自己的規則變成了別人的規則。那之後,作品集專案就擱置很久。不是因為我不會寫 UI,而是因為維持一致性的負擔,已經超過了我對它的耐心。

領悟

有一段時間,我一直把問題理解成「哪個工具能解決這件事」,所以我不斷拿各種框架互相比較,最後在它們底下都看見同一種結構的額外負擔:要整理的檔案、要手動維護的規則、以及在原始碼和實際執行頁面之間隔著的一個建置步驟。

真正的轉折點,是我不再問哪個框架比較好,而是開始問一個更不舒服的問題:如果問題根本不是框架呢?如果問題其實是我每次都在手動組裝 HTML、CSS 和 JS,不管我用哪個工具來做這件事?框架只是給我更好的方式來組織這份組裝工作,卻沒有把「你還是得組裝它」這件事移除。

LuciaCore 就是在那一刻真正開始的,不是為了找一個更好的框架,而是試著把手動組裝這一步徹底拿掉。如果瀏覽器本來就能原生執行 HTML、CSS 和 JS,那真正的問題就是:能不能在它上面再疊一層,讓它根據我想要什麼的描述,自動替我產生標記與行為,而不是要我親手寫出實作?

為什麼是 JSON

HTML 描述結構。CSS 描述外觀。JavaScript 描述行為。

LuciaCore 不打算取代這三者中的任何一個。它站在更上一層,描述的是開發者想要這個介面成為什麼,而不是某一層的實作方式。HTML、CSS、JS 都還是底層的實作,而我一直在對抗的不一致,正是從實作那一層不斷滲進來的,因為實作每次都得手動重做,而手動做的東西一定會漂移。

我想要的是一種描述意圖的方式:這是一個按鈕,它顯示這些文字,被點擊時做那件事,放在這裡。不是一個一個節點去組這個按鈕,而是說明按鈕應該是什麼。JSON 最後證明是很適合的載體,不是因為它很聰明,而是因為它只是資料。它本身沒有執行模型,所以沒有地方讓實作細節偷偷溜回來。真正負責把描述轉成實際 DOM 節點、實際的範圍樣式,以及實際事件監聽器的,是引擎,不是作者,而且不管誰寫的 JSON、什麼時候寫的,處理方式都一樣。

寫出來的描述像這樣:

{
  "type": "button",
  "label": "Get started",
  "action": "navigate::to::signup",
  "style": "background: #2563eb; color: white; padding: 12px 20px; border-radius: 8px;"
}

而引擎每次都會把它轉成這樣,不管誰寫的 JSON:

<button
  class="lucia-button"
  style="background:#2563eb; color:#fff; padding:12px 20px; border-radius:8px;"
  data-action="navigate::to::signup">
  Get started
</button>

JSON 裡沒有描述 DOM 節點怎麼建立、點擊監聽器怎麼掛上去、樣式怎麼做範圍限制才不會影響到頁面上的下一個按鈕。這些全都是引擎的責任,而且對每個節點都用同樣的方式處理,這正是我以前沒有的特性。

這就是 LuciaCore 的核心區別。它不是一個語法不同的元件庫,更接近一個宣告式 UI 執行環境:它待在瀏覽器裡,解讀一份介面描述,而不是一個你要設定、再拿去建置的框架。JSON 只是這份描述所採用的序列化格式,並不是最重要的部分;它只是把意圖寫下來的一種方便且不含糊的方法。我刻意避免使用「框架」這個詞,因為框架通常還是期待你寫出實作,只不過是在它規則之內。LuciaCore 要的是描述,並且自己處理實作。

設計詞彙

一旦我決定把 UI 當成資料來描述,我就需要決定最小可用的詞彙集是什麼。我把介面分成兩類:

  • 版面配置rowcolumngridcard,負責控制子項怎麼排列。
  • 原始元件buttontextinputimageloading……使用者實際看到和碰到的葉節點元素。

實務上,一小塊 UI 就只是這兩類彼此巢狀起來:

row
 └─ column
     ├─ text    "Welcome back"
     └─ button  "Sign in"  →  action: navigate::to::home

這棵樹裡沒有任何特殊案例。column 不需要知道或在意它的子項是其他版面配置還是原始元件,它只負責排列自己拿到的東西,這也是為什麼這兩類可以組合,而不是一組固定的頁面樣板。

我選擇用 JavaScript 來做引擎本身,理由其實很樸素:瀏覽器已經原生支援 HTML、CSS 和 JS,所以如果引擎本身就是 JS,那專案唯一需要額外帶上的,就只是一個 JSON 檔。不需要 npm install,不需要建置步驟,也不需要在原始碼和執行中的頁面之間再加一層編譯流程。

一旦版面配置和原始元件的分工成立,其他概念也幾乎自然冒出來:

  • type 宣告要渲染什麼:版面配置、原始元件、在同一個檔案裡定義的元件,或獨立模組,只要能解析成有效名稱即可。
  • style 是原始 CSS,會由引擎自動注入並做範圍限制,因此任何版面配置或原始元件都能視覺上覆寫,而不必碰引擎內部。
  • action 是我自己定義的一種系統呼叫字串,這個值得另外解釋。

action DSL

我不想讓 click handler 分散在整個程式碼庫裡,像我以前那樣,每一個都是一小段、形狀各不相同的命令式邏輯。我想要的是:行為也能像結構一樣被描述成意圖,而不是一串步驟。

所以 LuciaCore 裡的 action 只是像 navigate::to::homealert::messageapi::callstorage::set::key::value 這種純字串。每一個大致上都像一句話:動詞、目標、參數。navigate::to::home 沒有告訴引擎要怎麼切換頁面、怎麼拆掉舊 DOM,或怎麼還原捲動位置。它只是在說使用者想去哪裡,而「怎麼做」由引擎負責。api::call 也是一樣,它描述的是應該發出一個請求,而不是底下的 fetch 樣板程式碼。

綁上這些 action 的按鈕,不需要再附加任何其他東西:

{
  "type": "button",
  "label": "Save changes",
  "action": "api::call |> alert::Saved!"
}

點擊時,引擎讀取那串字串,然後按順序做兩件事:先執行 action 描述的請求,等第一步完成後,再顯示確認訊息。這顆按鈕的 fetch 呼叫、載入狀態,以及兩個步驟之間的串接,沒有任何一部分是手寫的。它只被宣告一次,寫在字串裡,然後對所有使用它的按鈕都用相同方式解讀。

這裡真正重要的區別,和引擎裡的其他地方一樣:action 字串說的是「應該發生什麼」,不是「要怎麼實作」。navigate::to::homeapi::call |> alert::Saved! 都是在宣告意圖,而不是程序。引擎當然還是得在某個地方寫出真正的命令式程式碼:fetch 呼叫、DOM 切換、事件綁定,但那些程式碼集中在引擎內部,而不是散落在專案裡每個 click handler 中、被稍微不同地重寫一遍。

這和我在版面配置與原始元件上做的取捨是一樣的。手寫的 onClick handler 屬於實作,而實作正是容易滋生不一致與一次性 bug 的地方。宣告式的 action 字串屬於意圖,並且在每個出現的位置都會被同樣地解讀。

至於真的太客製、沒辦法被這套詞彙涵蓋的部分,我沒有無止境地擴充 DSL,而是定義了一個解析契約。像 button::buy::shop::item::0 這種 type 會對應到 /button/buy/shop/item/0.js。如果它是渲染模組,契約就是 render 函式;如果它是 action 模組,契約就是 execute 函式。不管你是在擴充渲染還是行為,命名規則和解析邏輯都一樣。引擎不需要知道差別,直到它真的把檔案打開為止。

我也很早就意識到,如果把每個 UI 都直接塞進頁面的 JSON 裡,只會用新的格式重演我想逃避的檔案膨脹。所以我引入了元件:可重複使用的 UI 定義,透過 type 參照,並透過 props:item: token 把資料傳進去。我最後採用的規則很簡單,我覺得這也是最好的預設:被呼叫的節點永遠對自己的值有最終決定權。呼叫端提供預設值,被呼叫端可以覆蓋它們。這樣組合行為才會可預測。你可以單獨推理一個元件,不需要追蹤所有可能餵資料給它的呼叫端。

下面是完整流程的樣子。元件先一次性定義自己的形狀:

{
  "type": "custom_cta_button",
  "layout": {
    "type": "button",
    "label": "props:label",
    "action": "props:action",
    "style": "background:#16a34a; color:white; padding:10px 18px; border-radius:6px; props:style;"
  }
}

接著任何頁面都可以呼叫它,只提供不同的值:

{
  "type": "custom_cta_button",
  "props": {
    "label": "Start free trial",
    "action": "navigate::to::signup",
    "style": ""
  }
}

元件擁有自己的結構與樣式預設值;呼叫它的頁面只需要提供資料。雙方都不需要知道彼此的內部實作,也能正確協同運作。

回頭看,大概就是從這個時間點開始,專案不再只是 JSON schema,而更像是一種小型宣告式語言:類型、版面、動作、token,以及擴充這三者的解析契約。JSON 仍然只是這門語言剛好採用的語法。我不想把它講得太大,它只是有明確用途的小型語言,不是通用語言;但比起說它是「帶了一些慣例的資料格式」,用前者其實更準確。

兩個檔案,然後變成一個

引擎自然地分成兩個責任:action.js 負責解析與派發行為,render.js 負責讀取 JSON 並產生 HTML 與 CSS。要把它們各自整理到真的能撐住的狀態,花了不少平凡無奇的反覆:實作、弄壞、修正、重來。那段過程沒有任何聰明之處,就是工作量而已。

過程中我還做了比預期更激烈的重構。一開始延續作品集專案時那種純粹切檔案的習慣,我把每個原始元件都拆成獨立檔案:row.jsbutton.js,一個概念一個檔案。等引擎行為穩定後,我發現那樣的切分完全是額外負擔:版面配置和原始元件大約有 99% 的渲染邏輯都一樣,只差標籤名稱和預設樣式。我把它們全部收回引擎核心,不再散在各個檔案裡,這和當初造成混亂的習慣剛好反方向運作。

我加了 main.js 來串接 action.jsrender.js,並讀取 content.json 裡的 system 區塊、版本管理,以及在有設定時註冊 Service Worker 進行離線快取。完成那次重構後,整個引擎只剩三個精簡的檔案,接著我把它們打包並混淆成單一的 luciacore.engine.js,這也是專案唯一需要載入的腳本。

第一次真的有人回饋我

我自己跑了幾天之後,把它給一位我認識的工程師看。對方的反應很直接:上手很容易,原因很明確,就是現在有了一種標準化的 UI 描述方式,而且是由引擎一致地生成,不像我以前那樣每個人都各自發明自己的結構。你仍然可以客製任何東西,只是引擎會把它編譯成同一種一致的形狀,不管是誰寫的都一樣。

我也把它交給一位沒有工程背景的人試用,他一開始有點猶豫,原因不是核心想法有問題,而是我還沒把關注點切得夠清楚。版面和內容還放在同一份 JSON 裡,這代表你要改標題之前,得先理解 rowcolumnprops。這件事促使我把元件內容分開:

  • components 定義東西長什麼樣,使用版面配置和原始元件構成,是需要理解結構的部分。
  • contents 定義它說什麼,也就是實際文字和資料值,是非技術協作者唯一需要碰的部分。

這個切分打開了一扇我原本沒預料到的門:元件可以成為可分享、可直接貼上的片段。你不再需要問「可以給我你的 HTML、CSS 和 JS 嗎」,而是問「可以給我你的 component 嗎」,把 JSON 貼進自己的 content.json 就能運作,不需要對齊框架版本,也不需要協調建置設定。跟一段標記或 React 程式碼相比,這是一個更小、更封閉的交付單位,也比較不會讓人因為沒完全理解就不敢碰。

這對 AI 工具有什麼意義

這種結構有一個值得一提的副作用,但我想小心一點講,因為很容易誇大。不是語言模型讀了 LuciaCore 的 JSON 之後突然變聰明了,而是這種小而有邊界、形狀一致的 schema,本來就比數百個彼此鬆散相關、沒有共享結構連起來的 HTML、CSS、JS 檔案,對任何讀者——人或模型——都更容易一次放進工作記憶裡。

一個推理散落標記語法寫成的 UI 的模型,必須先重建檔案之間的關係:哪個 class 影響哪個元素、哪個 script 綁了哪個按鈕,之後才有辦法推理真正要做的變更。content.json 直接跳過了這個重建步驟,因為結構在建構時就已經明確且一致。頁面就是一棵有類型的節點樹;樣式是局部範圍;動作則緊貼著它所屬的節點直接宣告。十行這樣的 schema,比五百行實作細節更小、更可預測,不是因為模型更會讀程式碼,而是因為在開始推理意圖之前,需要解決的歧義少得多。

讓它變快:一段優化故事

把詞彙設計對了,並不代表它就跑得快;我想明確說明原因,因為這種失敗模式對任何在單執行緒上做遞迴樹狀渲染的系統來說都很常見。

引擎會透過遞迴走訪 JSON 樹,從中建立 HTML 和 CSS。瀏覽器裡的 JavaScript 是單執行緒,所以這段遞迴渲染工作,還有邏輯與網路工作——抓資料、執行動作——都在爭同一條執行緒。實際結果就是競爭:UI 必須等渲染完成才能畫出來,而渲染又得和當下正在進行的網路或邏輯工作分時間。頁面節點很多時,在筆電上打開要三到五秒,在手機上要十到十五秒。慢到一眼就感覺得到,不是可以忽略的誤差。

大致上,前後差異像這樣:

Before — 所有事情都卡在同一條執行緒
主執行緒: |--- 走訪 JSON 樹 ---|--- 抓資料 ---|--- 執行動作 ---|
            |                                                      |
            頁面開始載入                                   首次繪製(桌機 3–5 秒,手機 10–15 秒)

After — UI 先繪製,其餘工作在背景持續進行
主執行緒: |--- 繪製骨架畫面 ---|
            |                    |
            頁面開始載入         首次繪製(幾乎立即)
                                  |--- 抓資料 ---|--- hydrate 節點 ---|  (背景)

兩個改動依序解決了大部分問題。

第一個是讓主執行緒先讓出時間給瀏覽器的繪製循環,再做其他事情。與其把渲染走訪、邏輯和網路工作在同一條執行緒上一個接一個跑完,我重構了流程,讓 UI 可以先立刻畫出骨架畫面,先把執行權交還瀏覽器,讓它真的能開始繪製,之後再在背景繼續進行資料抓取和動作邏輯。這個排程改動並沒有讓任何單一工作變快,只是不再逼使用者在看到任何東西之前先等全部做完。

第二個是根據內容雜湊快取編譯後的頁面。如果自上次之後內容沒有變更,每次造訪都重新把整棵 JSON 樹編譯成 HTML 和 CSS,就是浪費。LuciaCore 現在會對 content.json 做雜湊,只有在雜湊真的改變時才重新編譯頁面,並把編譯結果儲存起來重複利用。這裡的快取失效設計得很單純:雜湊相符或不相符,沒有額外的失效邏輯要長期維護。之後的重複造訪,不管在筆電還是手機上,都因為不再每次載入都重新編譯而明顯變快。

這兩個改動都沒有改變引擎渲染出什麼,只是改變它什麼時候、多久做一次最花工夫的部分。通常在這種系統裡,真正的效能提升就藏在這裡:不是更快的演算法,而是不要重做已經做過的工作。

現在用 LuciaCore 開發長什麼樣子

整個設定只有兩個檔案。

index.html,在 VS Code 裡用 ! 再按 Tab 生成腳手架,然後加上一個 script 標籤:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My App powered by Lucia</title>
    <script defer src="https://cdn.jsdelivr.net/gh/thuangf45/luciacore.assets@master/luciacore.engine.js"></script>
</head>
<body>
</body>
</html>

content.json,真正的工作區,分成四個區塊:

{
  "system": { "version": "1.0.0", "debug": true },
  "pages": [
    {
      "name": "home",
      "layout": {
        "type": "column",
        "style": "padding: 40px; gap: 16px; align-items: center;",
        "children": [
          { "type": "text", "level": "h1", "label": "Hello LuciaCore" },
          { "type": "button", "label": "Click me", "action": "alert::It works!" }
        ]
      }
    }
  ],
  "contents": [],
  "components": []
}
  • system:引擎設定,例如除錯模式、版本管理、離線快取。
  • pages:應用程式的路由,透過 navigate::to::pageName 進入。
  • contents:可編輯的文字與資料,應由非技術協作者處理。
  • components:可重複使用的 UI 樣板,由版面配置與原始元件構成,透過 type 參照,並用 props:item: token 餵資料。

這就是開始時需要的全部心智模型。不需要套件管理器,也不需要建置步驟。引擎會在瀏覽器中、頁面載入當下完成那些工作。

它目前的狀態,以及我為什麼這樣做

我想直接說明目前狀態:LuciaCore 是原型。它還沒有經過成熟框架那種多年累積的各種邊界情境,我也不是在主張它應該取代誰,或者它比大家現在常用的工具跑得更快。我能更謹慎地說的是:它現在確實是一個可用的引擎,能用來建立真正可互動、可多頁切換的 UI,按鈕可以做事,頁面可以導向,資料可以從 API 載入,而且全部都能用資料來描述,而不是用程式碼。

我不是想取代瀏覽器。瀏覽器本來就已經把自己的工作做得很好。我想減少我自己必須手動管理的前端複雜度:命名規則、檔案膨脹、一次性 click handler、以及那些累積起來的不一致,最後讓一個小作品集網站花了一整個月才做完。把結構、行為與內容分成三個獨立而明確的東西,而不是混成一團標記和腳本,這就是 LuciaCore 的核心取捨,而這是我願意再做一次的取捨。

我用過的每個框架都代表不同的取捨,而它們沒有一個是免費的。React、Vue、Angular 在靈活度和結構之間,各自做了不同的交換,也各自適合不同的問題。LuciaCore 不是要在它們擅長的地方打敗它們,而是在探索另一種取捨:開發者描述意圖,執行環境負責實作,代價是邊界上的彈性會比通用框架少一些。

如果你也遇過同樣的牆,或者你看了這個東西,立刻就知道它在你的工作負載下會在哪裡壞掉,我反而更想聽聽你的看法。程式和文件都在 GitHub 上。


原文出處:https://dev.to/thuangf45/i-got-tired-of-maintaining-frontend-code-so-i-built-a-declarative-ui-runtime-5dbl


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝17   💬3   ❤️5
741
🥈
我愛JS
📝3   💬6   ❤️4
170
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登