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

本教程详细介绍了如何使用现代Web技术栈构建一个功能丰富的在线IDE,包含Monaco编辑器集成、AI代码建议、实时协作功能和开发者体验优化等核心技术实现。

项目概述

在本教程中,我们将使用现代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

接下来安装所需的依赖项:

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
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 } };  
};  

// 组件接收类型检查的用户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编辑器

我们将使用@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 {
      // 发送POST请求到Goose AI的建议端点
      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
4
5
6
const debouncedFetchSuggestion = useCallback(
  debounce((prompt: string, currentCode: string, cursorPosition: CursorPosition) => {
    fetchSuggestion(prompt, currentCode, cursorPosition);
  }, 500),
  []
);

为什么防抖在实时应用中至关重要

考虑一个在线IDE,用户在其中输入代码,应用程序提供实时反馈(如linting、代码建议或格式化)。如果没有防抖,每次按键都可能触发API调用,迅速使服务器不堪重负,并可能降低用户体验。

防抖在实时应用中的好处:

  • 减少服务器负载:通过将多个快速事件合并为一个,最大限度地减少API请求数量
  • 提高性能:减少不必要的操作数量,使应用程序更响应
  • 更好的用户体验:此功能减少延迟,并确保仅在用户暂停后应用程序才响应,防止抖动或压倒性的反馈

5. 从Goose AI获取建议

fetchSuggestion函数发送一个POST请求,其中包含提取的提示、当前代码上下文和

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计