Skip to main content

开发 todo app

配置 farrow.config.js#

首先,在 farrow.config.js 配置文件里,配置一下 farrow-api 的生成规则。

const { createFarrowConfig } = require('farrow');
module.exports = createFarrowConfig({
server: {
src: './server',
dist: './dist/server',
},
api: [
{
src: 'http://localhost:3003/api/todo',
dist: `${__dirname}/src/api/todo.ts`,
},
],
});

将接口 http://localhost:3003/api/todo,通过 codegen 生成到本地 ${__dirname}/src/api/todo.ts

运行 farrow dev 后,将在 dist 配置所指定的目录,新增文件。

/**
* This file was generated by farrow-api
* Don't modify it manually
*/
import { createApiPipelineWithUrl, ApiInvokeOptions } from 'farrow-api-client';
/**
* {@label Todo}
*/
export type Todo = {
/**
* @remarks todo id
*/
id: number;
/**
* @remarks todo content
*/
content: string;
/**
* @remarks todo status
*/
completed: boolean;
};
/**
* {@label AddTodoInput}
*/
export type AddTodoInput = {
/**
* @remarks todo content to add
*/
content: string;
};
/**
* {@label InvalidAddTodoInput}
*/
export type InvalidAddTodoInput = {
type: 'InvalidAddTodoInput';
message: string;
};
/**
* {@label AddTodoSuccess}
*/
export type AddTodoSuccess = {
type: 'AddTodoSuccess';
/**
* @remarks a new todo
*/
todo: Todo;
};
/**
* {@label AddTodoOutput}
*/
export type AddTodoOutput = InvalidAddTodoInput | AddTodoSuccess;
/**
* {@label RemoveTodoInput}
*/
export type RemoveTodoInput = {
/**
* @remarks todo id wait for removing
*/
todoId: number;
};
/**
* {@label TodoIdNotFound}
*/
export type TodoIdNotFound = {
type: 'TodoIdNotFound';
/**
* @remarks invalid todo id
*/
todoId: number;
};
/**
* {@label RemoveTodoSuccess}
*/
export type RemoveTodoSuccess = {
type: 'RemoveTodoSuccess';
/**
* @remarks todo id that removed
*/
todoId: number;
/**
* @remarks current todos
*/
todos: Todo[];
};
/**
* {@label UpdateTodoInput}
*/
export type UpdateTodoInput = {
/**
* @remarks todo id wait for update
*/
todoId: number;
/**
* @remarks new todo content
*/
content?: string | null | undefined;
/**
* @remarks new todo status
*/
completed?: boolean | null | undefined;
};
/**
* {@label UpdateTodoSuccess}
*/
export type UpdateTodoSuccess = {
type: 'UpdateTodoSuccess';
/**
* @remarks todo id that updated
*/
todoId: number;
/**
* @remarks current todos
*/
todos: Todo[];
};
/**
* {@label UpdateTodoOutput}
*/
export type UpdateTodoOutput = TodoIdNotFound | UpdateTodoSuccess;
export const url = 'http://localhost:3003/api/todo';
export const apiPipeline = createApiPipelineWithUrl(url);
export const api = {
/**
* @remarks get todos
*/
getTodos: (input: {}, options?: ApiInvokeOptions) =>
apiPipeline.invoke(
{ type: 'Single', path: ['getTodos'], input },
options
) as Promise<{
/**
* @remarks all todos
*/
todos: Todo[];
}>,
/**
* @remarks add todo
*/
addTodo: (input: AddTodoInput, options?: ApiInvokeOptions) =>
apiPipeline.invoke(
{ type: 'Single', path: ['addTodo'], input },
options
) as Promise<AddTodoOutput>,
/**
* @remarks remove todo
*/
removeTodo: (input: RemoveTodoInput, options?: ApiInvokeOptions) =>
apiPipeline.invoke(
{ type: 'Single', path: ['removeTodo'], input },
options
) as Promise<TodoIdNotFound | RemoveTodoSuccess>,
/**
* @remarks update todo
*/
updateTodo: (input: UpdateTodoInput, options?: ApiInvokeOptions) =>
apiPipeline.invoke(
{ type: 'Single', path: ['updateTodo'], input },
options
) as Promise<UpdateTodoOutput>,
/**
* @remarks clear completed
*/
clearCompleted: (input: {}, options?: ApiInvokeOptions) =>
apiPipeline.invoke(
{ type: 'Single', path: ['clearCompleted'], input },
options
) as Promise<{
/**
* @remarks current todos
*/
todos: Todo[];
}>,
};

如上,我们可以看到接口的类型定义,以及 api client 对象的调用函数,都生成出来了。

引用生成的模块#

然后,我们在 src/App.tsx 组件里,可以直接 import 生成的模块里包含的类型和对象。

import React, {
useState,
useEffect,
ChangeEventHandler,
MouseEventHandler,
KeyboardEventHandler,
} from 'react';
import { api as TodoApi, Todo } from './api/todo';

实现 TodoItem 组件#

再添加组件实现,先定义 TodoItem 组件,定义时我们直接用到了生成的 Todo 类型。

type TodoItemProps = {
todo: Todo;
onRemove: (todoId: Todo['id']) => unknown;
onUpdate: (
todoId: Todo['id'],
todoData: Partial<Omit<Todo, 'id'>>
) => unknown;
onToggle: (todo: Todo) => unknown;
};
function TodoItem(props: TodoItemProps) {
const [text, setText] = useState(props.todo.content);
const [editable, setEditable] = useState(false);
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
setText(event.target.value);
};
const handleDblClick: MouseEventHandler<HTMLLabelElement> = () => {
setEditable(true);
};
const handleUpdate = () => {
props.onUpdate(props.todo.id, {
content: text,
});
setEditable(false);
};
const handleKeyUp: KeyboardEventHandler<HTMLInputElement> = (event) => {
if (event.key === 'Enter') {
handleUpdate();
} else if (event.key === 'Escape') {
setText(props.todo.content);
setEditable(false);
}
};
const handleBlur = () => {
if (text !== props.todo.content) {
handleUpdate();
}
};
return (
<div>
<button onClick={() => props.onToggle(props.todo)}>
{props.todo.completed ? 'completed' : 'active'}
</button>
<button onClick={() => props.onRemove(props.todo.id)}>remove</button>
{!editable && <label onClick={handleDblClick}>{text}</label>}
{editable && (
<input
type="text"
value={text}
onChange={handleChange}
onKeyUp={handleKeyUp}
onBlur={handleBlur}
/>
)}
</div>
);
}

实现 App 组件#

然后定义 App 组件。

function App() {
const [todos, setTodos] = useState<Todo[]>([]);
const [text, setText] = useState('');
const handleAdd = async () => {
const result = await TodoApi.addTodo({
content: text,
});
if (result.type === 'InvalidAddTodoInput') {
alert(result.message);
} else if (result.type === 'AddTodoSuccess') {
setTodos(todos.concat(result.todo));
setText('');
}
};
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
setText(event.target.value);
};
const handleKeyUp: KeyboardEventHandler<HTMLInputElement> = (event) => {
if (event.key === 'Enter') {
handleAdd();
}
};
const handleRemove: TodoItemProps['onRemove'] = async (todoId) => {
let result = await TodoApi.removeTodo({
todoId: todoId,
});
if (result.type === 'TodoIdNotFound') {
alert(`todoId ${todoId} not found`);
} else {
setTodos(result.todos);
}
};
const handleUpdate: TodoItemProps['onUpdate'] = async (todoId, todoData) => {
let result = await TodoApi.updateTodo({
todoId: todoId,
content: todoData.content,
completed: todoData.completed,
});
if (result.type === 'TodoIdNotFound') {
alert(`todoId ${todoId} not found`);
} else {
setTodos(result.todos);
}
};
const handleToggle: TodoItemProps['onToggle'] = async (todo) => {
let result = await TodoApi.updateTodo({
todoId: todo.id,
completed: !todo.completed,
});
if (result.type === 'TodoIdNotFound') {
alert(`todoId ${todo.id} not found`);
} else {
setTodos(result.todos);
}
};
const handleClearCompleted = async () => {
let result = await TodoApi.clearCompleted({});
setTodos(result.todos);
};
useEffect(() => {
const task = async () => {
const result = await TodoApi.getTodos({});
setTodos(result.todos);
};
task().catch((error) => {
console.log('error', error);
});
}, []);
return (
<div>
<header>
<input
type="text"
placeholder="input your todo content"
onChange={handleChange}
value={text}
onKeyUp={handleKeyUp}
/>
<button onClick={handleClearCompleted}>clear completed</button>
</header>
<hr />
<main>
{todos.map((todo) => {
return (
<TodoItem
key={todo.id}
todo={todo}
onRemove={handleRemove}
onToggle={handleToggle}
onUpdate={handleUpdate}
/>
);
})}
</main>
</div>
);
}
export default App;

handleAddTodo 函数中,我们调用了 api.addTodo 接口,并处理了它返回的 Tagged Unions 类型。

const handleAdd = async () => {
const result = await TodoApi.addTodo({
content: text,
});
if (result.type === 'InvalidAddTodoInput') {
// 处理非法输入
alert(result.message);
} else if (result.type === 'AddTodoSuccess') {
// 处理成功情况
setTodos(todos.concat(result.todo));
setText('');
}
};

handleRemove 等其它接口中也是一样的模式。

总的来说,farrow-api 的最佳实践就是:

  • 在服务端,使用 Tagged Unions 编码接口的 input/output
  • 在客户端,使用 result.type 区分不同的 case 消费数据。

以上,我们完成了一个简单的 Todo App。完成代码,可访问仓库 farrow-js/farrow-vite-todo-app