Menu
CoddyTech

搜索页面

本课属于 Coddy Node.js 学习旅程的“Express 基础”单元。第 23 课,共 23 课。

搜索页面是一个表单,服务器会像处理其他 GET 请求一样处理它。

<form action="/books" method="get">
    <input name="author">
    <button>Find</button>
</form>

提交此表单会让浏览器跳转到 /books?author=,后面接着输入到名为 author 的字段中的内容。路由会从 req.query 中读取它。将这个词写回字段的 value,搜索后它就会继续显示在字段中;它来自访问者,所以要对它进行转义。

challenge icon

挑战

简单

Recipe Box 新增了一个搜索页面。访客输入一个词,页面会列出标题中包含这个词的食谱。

  1. 在 server.js 中,404 处理程序上方,添加一个异步的 /search 路由。它从 req.query.q 读取这个词;如果没有,就使用空字符串。
  2. 页面始终显示一个搜索表单:<form action="/search" method="get">,其中有一个名为 q 的文本框和一个按钮。提交表单后,会打开 /search?q=,后面接上这个词。
  3. 文本框会显示搜索的词:搜索 curry 后,文本框中会保留 curry。将这个词写入文本框的 value 属性,并先通过 escapeHtml 处理,因为它来自访客。
  4. 在表单下方,为 loadRecipes() 中标题包含这个词的每个食谱列出一个链接,不区分大小写:soup 和 SOUP 都能找到 Tomato Soup。每个链接都指向对应食谱的页面,并显示经过转义的标题,就像主页上的链接一样:<a href="/recipes/1">Tomato Soup</a>。
  5. 如果搜索了一个词,但没有标题包含它,就显示文本 No recipes found,而不是列表。

试试看:按“运行”并访问 /search:页面会显示空白表单。在文本框中输入 SOUP,然后按按钮。页面会列出 Tomato Soup,文本框中仍会显示 SOUP。搜索 pizza:显示 No recipes found。搜索 fish:会正确显示 Fish & Chips。然后向 /api/recipes 发送 POST 请求,内容为 {"title": "Pea Soup", "tags": ["soup"]},再搜索 soup:列表中也会出现 Pea Soup。

亲手试试

import { escapeHtml } from './html.js';
import { dayOfYear } from './day-of-year.js';
import express from 'express';
import { randomUUID } from 'node:crypto';
import { recipes } from './data.js';
import recipesRouter, { loadRecipes } from './routes/recipes.js';
import reviewsRouter from './routes/reviews.js';

const app = express();

app.use((req, res, next) => {
    console.log(`${req.method} ${req.url}`);
    next();
});

app.use((req, res, next) => {
    res.set('X-Request-Id', randomUUID());
    next();
});

app.use(express.json());

app.use(express.static('public'));

app.get('/', (req, res) => {
    const items = recipes.map((r) => `<li><a href="/recipes/${r.id}">${escapeHtml(r.title)}</a></li>`);
    res.send(`<link rel="stylesheet" href="/style.css"><h1>Recipe Box</h1><ul>${items.join('')}</ul>`);
});

// type('text') 会发送纯文本,因此每个标题都会单独显示在一行。
app.get('/recipes', (req, res) => {
    const { tag } = req.query;
    const list = tag ? recipes.filter((recipe) => recipe.tags.includes(tag)) : recipes;
    res.type('text').send(list.map((recipe) => recipe.title).join('\n'));
});

app.get('/recipes/:id', (req, res) => {
    const recipe = recipes.find((r) => r.id === Number(req.params.id));
    if (!recipe) {
        res.status(404).send('No such recipe');
        return;
    }
    res.send(recipe.title);
});

app.use('/api/recipes', recipesRouter);
app.use('/api/recipes/:id/reviews', reviewsRouter);

app.get('/api/today', async (req, res) => {
    const day = dayOfYear(req.query.date);
    if (day === null) {
        res.status(400).json({ error: 'Use a date like 2026-03-01' });
        return;
    }
    const recipes = await loadRecipes();
    res.json(recipes[day % recipes.length]);
});

app.get('/about', (req, res) => {
    res.send('Recipe Box collects family recipes.');
});

app.get('/menu', (req, res) => {
    res.redirect(301, '/recipes');
});

app.get('/menu/:id', (req, res) => {
    res.redirect(301, `/recipes/${req.params.id}`);
});

app.use((req, res) => {
    if (req.path.startsWith('/api/')) {
        res.status(404).json({ error: 'Not found' });
        return;
    }
    res.status(404).send('<h1>Page not found</h1><p><a href="/">Back to Recipe Box</a></p>');
});

app.use((err, req, res, next) => {
    console.error(err);
    res.status(500).json({ error: 'Something went wrong' });
});

app.listen(3000, () => {
    console.log('Recipe Box is running on port 3000');
});

Express 基础中的所有课

自己练习: Express 在线编辑器