iT邦幫忙

2025 iThome 鐵人賽

DAY 27
0

前言

歡迎來到 Day 27!在我們的 AI 面試官具備了評測純 JavaScript 的能力後,一個自然的延伸問題便是:我們能評測像 React 這樣的現代前端框架嗎?這是一個巨大的挑戰,因為它不僅僅是執行程式碼,還涉及到 JSX 語法、模組依賴等複雜問題。

今天,我們將正式迎接這個挑戰,並實作一個非常輕量的解決方案。我們將完全在自己的 Next.js 後端,利用 React 的原生伺服器端渲染 (SSR) 能力來完成這一切。

今日目標

✅ 理解安全性考量以及今天實作的方案限制。
✅ 透過獨立的腳本執行測試我們的方案是否能順利實現。
✅ 理解架構:明確 React 題和一般演算法題的不同評測策略。
✅ 安裝依賴:在專案中安裝 React 相關套件。
✅ 建立評測引擎:實作 react-executor.ts 模組,封裝所有評測邏輯。

⚠️ 重要:安全性考量與聲明

在我們開始之前,必須先說明一個重要的事實:本文的實作方案適合學習和個人使用,但不建議直接用於需要高度安全性的生產環境。
今天的文章中是使用renderToString函數將使用者的輸入轉為字串後再進行比較,雖然 renderToString() 是 React 官方 API,但它仍然會執行使用者提交的元件函式。這意味著惡意使用者可能提交包含無限迴圈或讀取伺服器敏感資訊(如環境變數)的程式碼。
今天的方案與生產環境的實作相差甚遠,僅僅是一個 POC的等級,務必不要在你的實際生產環境上做類似的實作,大致上的差異如以下表格,文末會有更多的參考資料。

項目 我們的學習專案(今天實作) 生產環境建議
執行環境 Next.js API Route(主執行緒) 獨立的子行程(child_process)或 Docker 容器
權限控制 無特別限制 Node.js Permission Model(--permission=none
資源限制 依賴 Next.js 預設 嚴格的 CPU、記憶體、執行時間限制
驗證方式 字串 includes 比對 使用 Cheerio 或 JSDOM 進行選擇器斷言

為什麼我們今天不直接實作生產級方案?

因為我們的目標是在有限時間內,專注於理解 React SSR 評測的核心概念。完整的安全實作本身就能寫成另一個系列文章。對於個人學習工具或受信任的使用者場景,今天的方案是完全夠用的,當然這也與我本身在做文章規劃時不夠嚴謹有關係,我並沒有考慮到剩餘的時間其實不足夠實作一個生產級的方案並讓讀者可以理解所有的內容,關於這點我很抱歉,專案文章的規劃我確實還有許多不足的地方,還請見諒。

好的,現在我們了解了風險和限制,讓我們開始今天的實作吧!

會前賽:本地可行性驗證 (Proof of Concept)

在我們一頭栽進專案的程式碼之前,讓我們先透過一個獨立的、最小化的腳本,來證明這個「後端原生評測」的想法是完全可行的,畢竟這也是我之前沒實際做過的領域,通常提到一般的程式碼執行我大概就停在隔離環境執行的概念,以前我並沒有仔細思考過市面上的測驗網站是怎麼做到類似的事情,實務上要做相關測試執行時我也多半依賴像是 React Testing Library 或是 cypress 之類的套件來做,所以碰到這種要從頭打起的情況下,我通常習慣會先弄個 POC 然後再看情況進行調整。

這個測試的目的是什麼?

我們的目標是證明,在一個純淨的 Node.js 環境中,我們可以:
✅ 接收一段包含 JSX 的 React 程式碼字串。
✅ 使用 Babel 將其即時轉譯成標準 JavaScript。
✅ 利用 react 和 react-dom/server 套件,將元件渲染成 HTML 字串。
✅ 驗證渲染後的 HTML 是否符合我們的預期。

如何執行測試?

你可以另外開啟一個新的空專案資料夾,或在目前的專案中安裝所需套件來執行。

安裝套件:

npm install @babel/standalone react react-dom

建立測試檔案 test-react-eval.js,並將下方的程式碼貼入。

const Babel = require('@babel/standalone');
const React = require('react');
const { renderToString } = require('react-dom/server');

console.log('🚀 測試:使用真正的 React 進行 SSR\n');
console.log('='.repeat(60));

// 測試用的 JSX 程式碼
const jsxCode = `
import React, { useState } from 'react';

function Counter({ initialCount = 0 }) {
  const [count, setCount] = useState(initialCount);
  
  return (
    <div className="counter">
      <p>計數: {count}</p>
      <button onClick={() => setCount(count + 1)}>增加</button>
      <button onClick={() => setCount(count - 1)}>減少</button>
    </div>
  );
}

export default Counter;
`;

console.log('\n📝 原始 JSX 程式碼:');
console.log('-'.repeat(60));
console.log(jsxCode);

// Step 1: 轉譯 JSX
console.log('\n🔄 Step 1: 轉譯 JSX (只處理 JSX,不管 import)...\n');

try {
  const result = Babel.transform(jsxCode, {
    presets: ['react'],
  });

  console.log('✅ 轉譯成功!');

  // Step 2: 移除 import/export,因為我們會手動提供
  console.log('\n🧹 Step 2: 清理 import/export 語句...\n');

  let cleanedCode = result.code
    .replace(/import\s+.*?from\s+['"]react['"];?\s*/g, '')
    .replace(/import\s+.*?from\s+['"]react\/.*?['"];?\s*/g, '')
    .replace(/export\s+default\s+/g, '')
    .trim();

  console.log('✅ 清理完成!');

  // Step 3: 建立函式並執行
  console.log('\n⚙️  Step 3: 建立 React 元件...\n');

  // 在閉包中執行,提供 React 和 useState
  const componentFactory = new Function(
    'React',
    'useState',
    `
    ${cleanedCode}
    return Counter;
    `
  );

  // 執行函式,傳入真正的 React
  const CounterComponent = componentFactory(React, React.useState);

  console.log('✅ 元件建立成功!');

  // Step 4: 渲染測試
  console.log('\n🎨 Step 4: 渲染 React 元件...\n');
  console.log('📤 渲染結果:');
  console.log('-'.repeat(60));

  // 測試案例 1: 預設值
  const element1 = React.createElement(CounterComponent, {});
  const html1 = renderToString(element1);
  console.log('\n測試 1 - 預設初始值:');
  console.log(html1);

  // 測試案例 2: initialCount = 5
  const element2 = React.createElement(CounterComponent, { initialCount: 5 });
  const html2 = renderToString(element2);
  console.log('\n測試 2 - 初始值為 5:');
  console.log(html2);

  // 測試案例 3: initialCount = -3
  const element3 = React.createElement(CounterComponent, { initialCount: -3 });
  const html3 = renderToString(element3);
  console.log('\n測試 3 - 初始值為 -3:');
  console.log(html3);

  // Step 5: 驗證
  console.log('\n' + '='.repeat(60));
  console.log('\n✔️  Step 5: 驗證結果...\n');

  const tests = [
    {
      name: '測試 1',
      html: html1,
      patterns: ['<div', '0', '計數', '增加', '減少'],
    },
    { name: '測試 2', html: html2, patterns: ['5', '增加', '減少'] },
    { name: '測試 3', html: html3, patterns: ['-3', '增加', '減少'] },
  ];

  let allPassed = true;

  tests.forEach((test) => {
    const missing = test.patterns.filter((p) => !test.html.includes(p));
    if (missing.length === 0) {
      console.log(`✅ ${test.name}: 通過`);
    } else {
      console.log(`❌ ${test.name}: 失敗 (缺少: ${missing.join(', ')})`);
      allPassed = false;
    }
  });

  console.log('\n' + '='.repeat(60));
  if (allPassed) {
    console.log('\n🎉 所有測試通過!這個方案可行!\n');
    console.log('✅ 你可以開始整合到專案中了。\n');
  } else {
    console.log('\n❌ 有測試失敗。\n');
  }
} catch (error) {
  console.error('\n❌ 錯誤:', error.message);
  console.error('\n請確保已安裝所需套件:');
  console.error('   npm install @babel/standalone react react-dom\n');
  console.error('完整錯誤:');
  console.error(error);
}

接著輸入指令測試一下這個腳本是否有回報預期的結果:

node test-react-eval.js

執行成功後,你會看到所有測試通過的訊息,這給了我們充足的信心,可以開始將這個邏輯整合到我們的專案中了!

順利的話你可以在終端機看到以下的訊息:

🚀 測試:使用真正的 React 進行 SSR

============================================================

📝 原始 JSX 程式碼:
------------------------------------------------------------

import React, { useState } from 'react';

function Counter({ initialCount = 0 }) {
  const [count, setCount] = useState(initialCount);
  
  return (
    <div className="counter">
      <p>計數: {count}</p>
      <button onClick={() => setCount(count + 1)}>增加</button>
      <button onClick={() => setCount(count - 1)}>減少</button>
    </div>
  );
}

export default Counter;


🔄 Step 1: 轉譯 JSX (只處理 JSX,不管 import)...

✅ 轉譯成功!

🧹 Step 2: 清理 import/export 語句...

✅ 清理完成!

⚙️  Step 3: 建立 React 元件...

✅ 元件建立成功!

🎨 Step 4: 渲染 React 元件...

📤 渲染結果:
------------------------------------------------------------

測試 1 - 預設初始值:
<div class="counter"><p>計數: <!-- -->0</p><button>增加</button><button>減少</button></div>

測試 2 - 初始值為 5:
<div class="counter"><p>計數: <!-- -->5</p><button>增加</button><button>減少</button></div>

測試 3 - 初始值為 -3:
<div class="counter"><p>計數: <!-- -->-3</p><button>增加</button><button>減少</button></div>

============================================================

✔️  Step 5: 驗證結果...

✅ 測試 1: 通過
✅ 測試 2: 通過
✅ 測試 3: 通過

============================================================

🎉 所有測試通過!這個方案可行!

✅ 你可以開始整合到專案中了。

補充說明: 關於 new Function() 的補充說明

你可能會好奇:「用 new Function() 安全嗎?」
在我們的使用場景(個人學習工具)中,它是可接受的:

優點:

  • 比 eval() 更可控
  • 不會存取外層作用域
  • 可以明確指定傳入的參數(React, useState)
  • 在嚴格模式下執行

限制:

  • 仍然會執行使用者的程式碼
  • 無法阻止無限迴圈或記憶體耗盡
  • 無法阻止存取 Node.js 內建模組(如 fs, child_process)

這終究只是一個暫時用來實證觀念的方案,要走到生產環境我們還是得走向獨立執行環境的解法。

Step 1: 最終架構設計 - 為不同任務選擇最適當的工具

我們將採用一個較為合理的混合策略,針對不同類型的題目使用最適合的工具:

策略 1:React 程式題 → Next.js 後端原生處理

原因:renderToString 是 React 官方為 SSR 設計的 API,風險可控,且無需外部服務,兼具效率與低成本。

策略 2:一般程式題 (演算法) → 繼續使用 Judge0

原因:這類題目需要執行任意的演算法邏輯,風險高,必須在 Judge0 的安全沙箱中完全隔離執行。

Step 2: 在專案中安裝真實依賴

回到我們的 AI 面試官專案,安裝今天需要的套件。

npm install react react-dom @babel/standalone

另外由於我們目前的專案使用 typescript,需要補上額外的開發環境套件去做型別的補足。

npm install --save-dev @types/react @types/react-dom @types/babel__standalone

Step 3: 打造 React 評測引擎

我們將把所有評測邏輯封裝在一個獨立的模組中。建立新檔案 app/lib/react-executor.ts

// app/lib/react-executor.ts
import Babel from '@babel/standalone';
import React from 'react';
import { renderToString } from 'react-dom/server';

// 為了方便管理,我們先定義好測試案例和結果的型別
export interface ReactTestCase {
  name: string;
  props?: Record<string, any>;
  expectedPatterns?: string[];
}

export interface TestCaseResult {
  name: string;
  passed: boolean;
  actual: string;
  expected?: string[];
  missing?: string[];
  error?: string;
}

/**
 * 評測 React 元件的主函式,整合了轉譯、建立、渲染和驗證的所有步驟。
 * @param userJsxCode 使用者提交的 JSX 程式碼字串
 * @param testCases 測試案例陣列
 * @returns 一個包含每個測試案例評測結果的 Promise
 */
export async function evaluateReactComponent(
  userJsxCode: string,
  testCases: ReactTestCase[]
): Promise<{ success: boolean; results: TestCaseResult[]; error?: string }> {
  try {
    // 步驟 1: 轉譯 JSX 為純 JavaScript
    const transpiledCode = Babel.transform(userJsxCode, {
      presets: ['react'],
    }).code;
    if (!transpiledCode) throw new Error('Babel 轉譯返回空程式碼');

    // 步驟 2: 清理 import/export 語句
    const cleanedCode = transpiledCode
      .replace(/import\s+.*?from\s+.*?['"];?\s*/g, '')
      .replace(/export\s+default\s+/g, '')
      .trim();

    // 步驟 3: 使用 new Function 從字串安全地建立元件
    const componentFactory = new Function('React', 'useState', `
      ${cleanedCode}
      // 假設使用者的元件名稱為 Counter,未來可改進為更通用的方式
      return Counter; 
    `);
    const UserComponent = componentFactory(React, React.useState);
    if (typeof UserComponent !== 'function') {
      throw new Error('從程式碼建立的元件不是一個有效的函式');
    }

    // 步驟 4: 執行所有測試案例
    const results = testCases.map((testCase): TestCaseResult => {
      try {
        const element = React.createElement(UserComponent, testCase.props || {});
        const actualHtml = renderToString(element);
        const expectedPatterns = testCase.expectedPatterns || [];
        const missing = expectedPatterns.filter(pattern => !actualHtml.includes(pattern));
        
        return {
          name: testCase.name,
          passed: missing.length === 0,
          actual: actualHtml,
          expected: expectedPatterns,
          missing: missing.length > 0 ? missing : undefined,
        };
      } catch (renderError: any) {
        return {
          name: testCase.name,
          passed: false,
          actual: `渲染時發生錯誤: ${renderError.message}`,
        };
      }
    });

    return { success: true, results };

  } catch (error: any) {
    // 捕捉轉譯或元件建立階段的錯誤
    return { success: false, results: [], error: error.message };
  }
}

程式碼摘要說明

  • evaluateReactComponent:這是我們評測引擎的唯一對外函式,它接收使用者程式碼和測試案例,並回傳一個結構化的結果。
  • Babel 轉譯與清理:我們使用 Babel 將 JSX 轉譯,並用正規表示式移除非必要的 import/export 語句。
  • new Function:這是從字串動態建立函式的標準方法。我們將清理後的程式碼作為函式主體,並將 React 和 useState 作為參數注入進去,最後回傳使用者定義的元件函式。
  • 渲染與驗證:我們遍歷每個測試案例,使用 React.createElement 傳入對應的 props,再用 renderToString 將其轉換為 HTML。最後,比對 expectedPatterns,產生出詳細的測試結果。
  • 錯誤處理:整個過程被 try...catch 包裹,任何環節(轉譯、元件建立、渲染)出錯,都會被捕捉並以統一的格式回傳。

今日回顧

今天,我們完成了一個技術上相當有挑戰性的飛躍,為我們的 AI 面試官增添了評測 React 元件的強大能力。

✅ 完成了本地的測試腳本:確認我們的轉譯方案是可行的測試方案。
✅ 理解了安全性權衡:我們知道這個方案適合個人學習,並了解了生產環境需要更強的安全措施。
✅ 確立了混合架構:明確了 React 題用 SSR、Algorithm 題用 Judge0 的策略。
✅ 建立了評測引擎:從零開始,實作了一個功能完整、包含錯誤處理的 react-executor.ts 模組。

明日預告

我們強大的新引擎已經準備就緒!明天 (Day 28),我們將進行最後的整合工作。我們會將 react-executor 引擎正式接入 /api/interview/evaluate 路由,並設計一個新的 Prompt,將結構化的測試結果傳遞給 Gemini,讓它能基於客觀的測試結果,產生出高品質的、人性化的程式碼評論,完成整個 React 評測功能的閉環!

🔖 參考資源

本文使用的技術

安全性進階閱讀

業界實務案例


上一篇
應用程式控管:真實世界的成本、安全與容錯機制初探
下一篇
React 評測:最小化整合
系列文
前端工程師的AI應用開發實戰:30天從Prompt到Production - 以打造AI前端面試官為例29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言