使用 Next.js 15、TypeScript、Tailwind CSS 和 Goose AI 构建类 VS Code 在线 IDE

本教程详细介绍了如何使用现代 Web 技术构建一个功能丰富的在线 IDE,包含 Monaco 代码编辑器、AI 驱动的实时代码建议、响应式 UI 设计,以及高级 TypeScript 集成和开发者体验优化方案。

构建类 VS Code 在线 IDE

在本教程中,我们将使用现代 Web 技术构建一个受 Visual Studio Code 启发的在线 IDE:Next.js 15、TypeScript、Tailwind CSS 和 Goose AI 的 API。该 IDE 将根据您输入的内容或任何内联注释提示提供实时代码建议。

通过本指南,您将获得一个交互式编码环境,具有以下功能:

  • 由 Monaco Editor(VS Code 使用的相同编辑器)提供支持的代码编辑器
  • 实时代码建议(利用 Goose AI 的 API)
  • 使用 Tailwind CSS 设计的响应式现代 UI

项目设置

首先,让我们使用 TypeScript 创建一个新的 Next.js 15 项目。打开终端并运行:

1
2
npx create-next-app@latest online-ide --typescript
cd online-ide

接下来,安装我们需要的依赖项。我们将使用:

  • @monaco-editor/react 用于代码编辑器
  • Axios 用于 API 请求
  • lodash.debounce 用于防抖 API 调用

运行以下命令:

1
npm install @monaco-editor/react axios lodash.debounce

最后,安装 Tailwind CSS:

1
2
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

然后,通过设置内容路径配置您的 tailwind.config.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// tailwind.config.js
module.exports = {
  content: [
    './pages/**/*.{js,ts,jsx,tsx}', 
    './components/**/*.{js,ts,jsx,tsx}'
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

并将 Tailwind 指令添加到您的全局 CSS 文件(styles/globals.css):

1
2
3
@tailwind base;
@tailwind components;
@tailwind utilities;

Next.js 和 TypeScript 集成详解

Next.js 和 TypeScript 形成了构建健壮、可维护 Web 应用程序的强大组合。本指南探讨了它们的协同作用,重点关注服务器/客户端渲染、大规模 IDE 优势以及带有注释代码示例的实用类型模式。

1. Next.js 如何简化与 TypeScript 的服务器/客户端渲染

Next.js 提供内置的 TypeScript 支持,支持类型安全的渲染策略:

A. 使用 getStaticProps 的静态站点生成 (SSG)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// pages/blog/[slug].tsx  
import { GetStaticProps, InferGetStaticPropsType } from 'next';  

// 1. 定义博客文章数据的类型
interface BlogPost {  
  slug: string;  
  title: string;  
  content: string;  
}  

// 2. 使用 InferGetStaticPropsType 对 props 进行类型化
export default function BlogPage({  
  post  
}: InferGetStaticPropsType<typeof getStaticProps>) {  
  return (  
    <article>  
      <h1>{post.title}</h1>  
      <p>{post.content}</p>  
    </article>  
  );  
}  

// 3. 对静态 props 进行类型检查
export const getStaticProps: GetStaticProps<{ post: BlogPost }> = async ({ params }) => {  
  const res = await fetch(`https://api.example.com/posts/${params?.slug}`);  
  const post: BlogPost = await res.json();  

  // 4. 返回类型化的 props(在构建时验证)
  return { props: { post } };  
};

主要优势:

  • 通过 InferGetStaticPropsType 进行 props 的类型推断
  • API 响应形状的编译时验证

B. 使用 getServerSideProps 的服务器端渲染 (SSR)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// pages/user/[id].tsx  
import { GetServerSideProps } from 'next';  

interface UserProfile {  
  id: string;  
  name: string;  
  email: string;  
}  

export const getServerSideProps: GetServerSideProps<{ user: UserProfile }> = async (context) => {  
  // 对路由参数进行类型安全访问
  const { id } = context.params as { id: string };

  const res = await fetch(`https://api.example.com/users/${id}`);  
  const user: UserProfile = await res.json();  

  return { props: { user } };  
};  

// 组件接收类型检查过的 user prop
export default function UserProfile({ user }: { user: UserProfile }) {  
  return (  
    <div>  
      <h2>{user.name}</h2>  
      <p>{user.email}</p>  
    </div>  
  );  
}

2. TypeScript 在大规模 IDE 项目中的优势

A. 增强的开发者体验

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// utils/api.ts  
interface ApiResponse<T> {  
  data: T;  
  error?: string;  
}  

// API 调用的泛型类型
export async function fetchData<T>(url: string): Promise<ApiResponse<T>> {  
  try {  
    const res = await fetch(url);  
    const data: T = await res.json();  
    return { data };  
  } catch (error) {  
    return { data: null as T, error: error.message };  
  }  
}  

// 在组件中的使用(VS Code 显示类型提示)
const { data, error } = await fetchData<UserProfile>('/api/users/123');  
// data 自动推断为 UserProfile | null

IDE 优势:

  • API 响应的自动完成
  • 类型不匹配的即时反馈

B. 使用 Props 接口的组件契约

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// components/Button.tsx  
interface ButtonProps {  
  children: React.ReactNode;  
  variant?: 'primary' | 'secondary';  
  onClick: () => void;  
}  

export const Button = ({ children, variant = 'primary', onClick }: ButtonProps) => {  
  return (  
    <button  
      className={`btn-${variant}`}  
      onClick={onClick}  
    >  
      {children}  
    </button>  
  );  
};  

// 如果使用不正确会出现类型错误:
<Button variant="tertiary">Click</Button> // 'tertiary' 不可分配

3. Next.js 的高级类型模式

A. 使用类型保护进行动态路由参数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// pages/products/[category].tsx  
import { useRouter } from 'next/router';  

type ValidCategory = 'electronics' | 'books' | 'clothing';  

const ProductCategoryPage = () => {  
  const router = useRouter();  
  const { category } = router.query;  

  // 类型保护以验证类别
  const isValidCategory = (value: any): value is ValidCategory => {  
    return ['electronics', 'books', 'clothing'].includes(value);  
  };  

  if (!isValidCategory(category)) {  
    return <div>Invalid category!</div>;  
  }  

  // category 现在缩小为 ValidCategory
  return <div>Showing {category} products</div>;  
};

B. API 路由类型化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// pages/api/users/index.ts  
import type { NextApiRequest, NextApiResponse } from 'next';  
interface User {  
  id: string;  
  name: string;  
}  

type ResponseData = {  
  users?: User[];  
  error?: string;  
}; 

export default function handler(  
  req: NextApiRequest,  
  res: NextApiResponse<ResponseData>  
) {  
  if (req.method === 'GET') {  
    const users: User[] = [  
      { id: '1', name: 'Alice' },  
      { id: '2', name: 'Bob' }  
    ];  
    res.status(200).json({ users });  
  } else {  
    res.status(405).json({ error: 'Method not allowed' });  
  }  
}

C. 应用范围类型扩展

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// types/next.d.ts  
import { NextComponentType } from 'next';  

declare module 'next' {  
  interface CustomPageProps {  
    theme?: 'light' | 'dark';  
  }

  type NextPageWithLayout<P = {}, IP = P> = NextComponentType<  
    any,  
    IP,  
    P & CustomPageProps  
  > & {  
    getLayout?: (page: ReactElement) => ReactNode;  
  };  
}

// 在 _app.tsx 中的使用
type AppProps = {  
  Component: NextPageWithLayout;  
  pageProps: CustomPageProps;  
};  

function MyApp({ Component, pageProps }: AppProps) {  
  const getLayout = Component.getLayout || ((page) => page);  
  return getLayout(<Component {...pageProps} />);  
}

为什么 TypeScript + Next.js 可扩展

  1. 类型安全渲染

    • 在构建时验证 SSG/SSR 的 props
    • 防止动态路由中的运行时错误
  2. IDE 超能力

    • API 响应的自动完成
    • 开发期间的即时反馈
  3. 架构完整性

    • 强制执行组件契约
    • 在大型团队中保持一致的数据形状

开始使用:

1
npx create-next-app@latest --typescript

通过将 Next.js 的渲染优化与 TypeScript 的类型系统相结合,团队可以自信地构建可维护的应用程序,即使在企业规模下也是如此。

集成 Monaco Editor

我们将使用 @monaco-editor/react 将 Monaco Editor 嵌入到我们的 Next.js 应用程序中。编辑器将是我们 IDE 中的主要工作空间。

pages/index.tsx 创建或更新主页面,代码如下:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
// pages/index.tsx
import { useState, useCallback, useRef } from 'react';
import dynamic from 'next/dynamic';
import axios from 'axios';
import debounce from 'lodash.debounce';

// 动态导入 Monaco Editor,使其仅在客户端加载。
const MonacoEditor = dynamic(
  () => import('@monaco-editor/react').then(mod => mod.default),
  { ssr: false }
);

type CursorPosition = {
  column: number;
  lineNumber: number;
};

const Home = () => {
  // 存储编辑器当前代码的状态。
  const [code, setCode] = useState<string>(`// Start coding here...
function helloWorld() {
  console.log("Hello, world!");
}

// Write a comment below to get a suggestion
//`);
  // 存储从 Goose AI 获取的建议的状态。
  const [suggestion, setSuggestion] = useState<string>('');
  // 处理加载指示器的状态。
  const [loading, setLoading] = useState<boolean>(false);
  // 处理错误的状态。
  const [error, setError] = useState<string>('');

  // Ref 用于存储 Monaco Editor 实例,以访问如 getPosition 等方法。
  const editorRef = useRef<any>(null);

  /**
   * 如果最后一行以 // 开头,则从中提取提示。
   * 
   * @param codeText - 编辑器中的完整文本。
   * @returns 修剪后的注释文本,如果未找到则返回 null。
   */
  const extractCommentPrompt = (codeText: string): string | null => {
    const lines = codeText.split('\n');
    const lastLine = lines[lines.length - 1].trim();
    if (lastLine.startsWith('//')) {
      // 移除注释标记并返回文本。
      return lastLine.slice(2).trim();
    }
    return null;
  };

  /**
   * 防抖函数用于调用 Goose AI API。
   * 这可以防止用户在输入时进行过多的 API 调用。
   */
  const debouncedFetchSuggestion = useCallback(
    debounce((prompt: string, currentCode: string, cursorPosition: CursorPosition) => {
      fetchSuggestion(prompt, currentCode, cursorPosition);
    }, 500),
    []
  );

  /**
   * 使用提供的提示、代码上下文和光标位置调用 Goose AI 的 API。
   * 
   * @param prompt - 从代码中提取的注释提示。
   * @param currentCode - 编辑器的当前内容。
   * @param cursorPosition - 编辑器中当前的光标位置。
   */
  const fetchSuggestion = async (
    prompt: string,
    currentCode: string,
    cursorPosition: CursorPosition
  ) => {
    setLoading(true);
    setError('');
    try {
      // 向 Goose AI 的建议端点发送 POST 请求。
      const response = await axios.post(
        'https://api.goose.ai/v1/suggestions',
        {
          prompt,
          codeContext: currentCode,
          cursorPosition,
          language: 'javascript'
        },
        {
          headers: {
            'Authorization': `Bearer ${process.env.NEXT_PUBLIC_GOOSE_AI_API_KEY}`,
            'Content-Type': 'application/json'
          }
        }
      );
      // 使用返回的建议更新建议状态。
      setSuggestion(response.data.suggestion);
    } catch (err) {
      console.error('Error fetching suggestion:', err);
      setError('Error fetching suggestion. Please try again.');
    } finally {
      setLoading(false);
    }
  };

  /**
   * 处理编辑器中的更改。更新代码状态,
   * 提取提示(如果有),并触发防抖的 API 调用。
   * 
   * @param newValue - 编辑器中的新代码。
   */
  const handleEditorChange = (newValue: string) => {
    setCode(newValue);
    const prompt = extractCommentPrompt(newValue);
    if (prompt) {
      // 从编辑器实例检索当前光标位置。
      const position = editorRef.current?.getPosition();
      if (position) {
        // 触发防抖的 API 调用。
        debouncedFetchSuggestion(prompt, newValue, position);
      }
    }
  };

  /**
   * 当 Monaco Editor 挂载时调用。
   * 存储对编辑器实例的引用以供以后使用。
   * 
   * @param editor - Monaco Editor 实例。
   */
  const editorDidMount = (editor: any) => {
    editorRef.current = editor;
  };

  /**
   * 在当前光标位置将获取的建议插入编辑器。
   */
  const acceptSuggestion = () => {
    if (editorRef.current && suggestion) {
      const position = editorRef.current.getPosition();
      // 创建插入建议的编辑操作。
      const id = { major: 1, minor: 1 }; // 编辑标识符。
      const op = {
        identifier: id,
        // 在当前光标位置定义插入范围。
        range: new editorRef.current.constructor.Range(
          position.lineNumber,
          position.column,
          position.lineNumber,
          position.column
        ),
        text: suggestion,
        forceMoveMarkers: true
      };
      // 在编辑器中执行编辑操作。
      editorRef.current.executeEdits('insert-suggestion', [op]);
      // 插入后可选地清除建议。
      setSuggestion('');
    }
  };

  return (
    <div className="flex h-screen">
      {/* 主代码编辑器部分 */}
      <div className="flex-1">
        <MonacoEditor
          height="100%"
          language="javascript"
          theme="vs-dark"
          value={code}
          onChange={handleEditorChange}
          editorDidMount={editorDidMount}
          options={{
            automaticLayout: true,
            fontSize: 14,
          }}
        />
      </div>

      {/* 建议侧边栏 */}
      <div className="w-80 p-4 bg-gray-800 text-white overflow-y-auto">
        <h3 className="text-lg font-bold mb-2">Suggestions</h3>
        {loading && <p>Loading suggestion...</p>}
        {error && <p className="text-red-500">{error}</p>}
        {suggestion && (
          <div>
            <pre className="whitespace-pre-wrap bg-gray-700 p-2 rounded">
              {suggestion}
            </pre>
            <button
              onClick={acceptSuggestion}
              className="mt-2 bg-blue-500 hover:bg-blue-600 text-white py-1 px-2 rounded"
            >
              Accept Suggestion
            </button>
          </div>
        )}
        {!loading && !suggestion && !error && (
          <p className="text-gray-400">Type a comment for a suggestion.</p>
        )}
      </div>
    </div>
  );
};

export default Home;

详细代码解释

1. Monaco Editor 的动态导入

我们使用 Next.js 的动态导入仅在客户端加载 Monaco Editor(因为它依赖于浏览器环境)。这避免了服务器端渲染问题:

1
2
3
4
const MonacoEditor = dynamic(
  () => import('@monaco-editor/react').then(mod => mod.default),
  { ssr: false }
);

2. 状态管理和编辑器引用

  • code:保存编辑器中的当前代码。
  • suggestion:存储从 Goose AI 获取的建议。
  • loadingerror:在 API 调用期间管理 UI 的响应。
  • editorRef:一个 React ref,使我们能够直接访问 Monaco Editor 的 API(例如,获取光标位置或执行编辑)。

3. 提取注释提示

extractCommentPrompt 函数检查代码的最后一行。如果它以 // 开头,它会移除标记并将注释文本作为 API 的提示返回。

4. 防抖 API 调用

使用 lodash.debounce,我们延迟 API 调用,直到用户停止输入后 500 毫秒。这最大限度地减少了不必要的请求:

1
2
3
const debouncedFetchSuggestion = useCallback(
  debounce((prompt: string, currentCode: string, cursorPosition: CursorPosition) => {
    fetchSuggestion(prompt, currentCode, cursorPosition);
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计