在没有SSR的情况下向React应用添加动态元标签

682 阅读7分钟

元标签是特殊的HTML标签,向搜索引擎和网站访问者提供关于你的网页的更多细节。

从这个定义中你可以推断出,元标签对搜索引擎优化(SEO)至关重要。不仅如此,你有没有看到过当你在Facebook或Twitter等社交平台上分享一个链接时出现的漂亮预览?这要归功于元标签。

因此,如果你想让你的应用程序在搜索结果和社交媒体及信息平台上脱颖而出,你需要设置元标签。特别是,你应该始终指定开放图谱协议的元标签,这是用来提供网络上任何网页信息的最常用协议。

在React应用程序中,有两种主要的方式来做到这一点。如果你的元标签是静态的,只要把它们写在你的应用程序的index.html ,你就可以了。

如果你想根据你项目中的不同URL设置动态元标签(例如:/home,/about,/detail?id=1,/detail?id=2 ),你必须在服务器端完成。网络爬虫在检查网页时并不总是执行JavaScript代码,所以如果你想确保它们能读到你的元标签,你需要在浏览器接收页面之前设置它们。

现在,把你带到这里的问题来了:如果我的应用没有服务器端渲染(SSR)怎么办?在这篇文章中,我们将看到一个简单而有效的解决方案应用于这个真实世界的场景。

我们将做什么

让我们假设你有一个用Create React App(CRA)创建的博客。你的博客有两条路线。

  1. / ,主页,用户可以在这里看到一个帖子的列表
  2. /post?id=<POST-ID> ,它指向一个特定的博客文章

<POST-ID> 第二条路线是我们需要放置动态元标签的地方,因为我们想根据作为查询字符串传递的og:title,og:description, 和og:image 标签来改变。

为了实现这一点,我们将从Node/Express后端为我们的应用程序提供服务。在向浏览器返回响应之前,我们将在页面的<head> 中注入所需的标签。

让我们来组织一下

通过运行npx create-react-app dynamic-meta-tags 来创建项目。我将保留CRA的启动模板,这样我们就可以直接关注我们感兴趣的点了。

在我们进入后端代码之前,我们需要在index.html 页面中添加标签占位符。稍后,我们将在返回页面之前用帖子信息替换这些占位符。

  <head>
    ...
    <meta name="description" content="__META_DESCRIPTION__"/>
    <meta name="og:title" content="__META_OG_TITLE__"/>
    <meta name="og:description" content="__META_OG_DESCRIPTION__"/>
    <meta name="og:image" content="__META_OG_IMAGE__"/>
    ...
  </head>

在与src 文件夹同级的位置添加一个server 文件夹,并创建一个新的index.js 文件。这就是项目结构应该是这样的。

Sample project structure

设置 Node/Express 后台

npm i express 安装Express,并打开server/index.js 文件。让我们开始编写我们的后端。

首先要做的是配置一个中间件,为构建文件夹中的静态资源提供服务。

const express = require('express');
const app = express();
const path = require('path');
const PORT = process.env.PORT || 3000;

// static resources should just be served as they are
app.use(express.static(
    path.resolve(__dirname, '..', 'build'),
    { maxAge: '30d' },
));

然后,我们准备好服务器,使其在定义的端口上进行监听。

app.listen(PORT, (error) => {
    if (error) {
        return console.log('Error during app startup', error);
    }
    console.log("listening on " + PORT + "...");
});

为测试目的,我在server/stub/posts.js 中创建了一个静态的帖子列表。正如你在下面的代码中看到的,每个帖子都有一个标题、描述和缩略图。getPostById 是我们用来从列表中获取特定帖子的函数。

const posts = [
    {
        title: "Post #1",
        description: "This is the first post",
        thumbnail: "https://images.unsplash.com/photo-1593642532400-2682810df593?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=750&q=80"
    },
    {
        title: "Post #2",
        description: "This is the second post",
        thumbnail: "https://images.unsplash.com/photo-1625034712314-7bd692b60ecb?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=750&q=80"
    },
    {
        title: "Post #3",
        description: "This is the third post",
        thumbnail: "https://images.unsplash.com/photo-1625034892070-6a3cc12edb42?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=766&q=80"
    }
]
module.exports.getPostById = id => posts[id-1];

当然,在一个真实的项目中,这些数据可以从数据库或其他远程资源中获取。

处理请求

现在我们可以专注于主处理程序。

 // here we serve the index.html page
app.get('/*', (req, res, next) => {
  // TODO
});

下面是我们要做的事情。

  1. 从构建文件夹中读取index.html 页面
  2. 获取请求的帖子
  3. 用帖子的数据替换元标签的占位符
  4. 返回HTML数据

第一步是将索引页加载到内存中。为了做到这一点,我们利用fs 模块中的readFile 函数。

const indexPath  = path.resolve(__dirname, '..', 'build', 'index.html');
app.get('/*', (req, res, next) => {
    fs.readFile(indexPath, 'utf8', (err, htmlData) => {
        if (err) {
            console.error('Error during file reading', err);
            return res.status(404).end()
        }
        // TODO get post info

        // TODO inject meta tags
    });
});

一旦我们得到它,我们就使用getPostById ,根据作为查询字符串传递的ID,得到所要求的帖子。

app.get('/*', (req, res, next) => {
    fs.readFile(indexPath, 'utf8', (err, htmlData) => {
        if (err) {
            console.error('Error during file reading', err);
            return res.status(404).end()
        }
        // get post info
        const postId = req.query.id;
        const post = getPostById(postId);
        if(!post) return res.status(404).send("Post not found");

        // TODO inject meta tags
    });
});

接下来,我们用帖子的标题、描述和缩略图替换占位符。

app.get('/*', (req, res, next) => {
    fs.readFile(indexPath, 'utf8', (err, htmlData) => {
        if (err) {
            console.error('Error during file reading', err);
            return res.status(404).end()
        }
        // get post info
        const postId = req.params.id;
        const post = getPostById(postId);
        if(!post) return res.status(404).send("Post not found");

        // inject meta tags
        htmlData = htmlData.replace(
            "<title>React App</title>",
            `<title>${post.title}</title>`
        )
        .replace('__META_OG_TITLE__', post.title)
        .replace('__META_OG_DESCRIPTION__', post.description)
        .replace('__META_DESCRIPTION__', post.description)
        .replace('__META_OG_IMAGE__', post.thumbnail)
        return res.send(htmlData);
    });
});

我们还用帖子的标题替换了默认的页面标题。

最后,我们将HTML数据发送到客户端。

概括地说,这就是我们的server/index.js ,应该是这个样子的。

const express = require('express');
const path = require('path');
const fs = require("fs"); 
const { getPostById } = require('./stub/posts');
const app = express();

const PORT = process.env.PORT || 3000;
const indexPath  = path.resolve(__dirname, '..', 'build', 'index.html');

// static resources should just be served as they are
app.use(express.static(
    path.resolve(__dirname, '..', 'build'),
    { maxAge: '30d' },
));
// here we serve the index.html page
app.get('/*', (req, res, next) => {
    fs.readFile(indexPath, 'utf8', (err, htmlData) => {
        if (err) {
            console.error('Error during file reading', err);
            return res.status(404).end()
        }
        // get post info
        const postId = req.query.id;
        const post = getPostById(postId);
        if(!post) return res.status(404).send("Post not found");

        // inject meta tags
        htmlData = htmlData.replace(
            "<title>React App</title>",
            `<title>${post.title}</title>`
        )
        .replace('__META_OG_TITLE__', post.title)
        .replace('__META_OG_DESCRIPTION__', post.description)
        .replace('__META_DESCRIPTION__', post.description)
        .replace('__META_OG_IMAGE__', post.thumbnail)
        return res.send(htmlData);
    });
});
// listening...
app.listen(PORT, (error) => {
    if (error) {
        return console.log('Error during app startup', error);
    }
    console.log("listening on " + PORT + "...");
});

运行测试

测试我们的应用程序

为了运行该应用程序,我们首先需要用npm run build ,生成一个新的构建,然后我们可以用node server/index.js ,运行服务器。

另外,你也可以在你的package.json 文件中定义一个新的脚本来自动完成这项任务。如下图所示,我把它叫做 "server",所以现在我可以用npm run server 来运行这个应用程序。

"scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "server" : "npm run build&&node server/index.js"
}

如果一切正常,你的应用程序现在正在运行在 [http://localhost:3000](http://localhost:3000).在我的例子中,它只是显示CRA的默认主页。

测试我们的动态元标签

现在,让我们来测试一下对我们真正重要的东西:元标签

你应该通过打开URL来获得第一篇文章的正确元标签 [http://localhost:3000/post?id=1](http://localhost:3000/post?id=1).你可以通过打开检查面板查看页面并在<head> 标签中看到它们。

Correct meta tag testing results

同样的情况应该发生在帖子2和帖子3上。

在发布应用程序之前测试页面预览

如果你需要在发布应用程序之前测试你的页面预览,你可以使用像opengraph.xyz这样的平台,让你通过检查页面的元标签来测试预览。为了测试它,我们需要一个可公开访问的URL。

为了获得我们本地环境的公共URL,我们可以使用一个叫做localtunnel的工具。在用npm i localtunnel 安装它之后,我们可以通过执行lt --port 3000 来运行它。它将连接到隧道服务器,建立隧道,并给我们提供测试用的URL。

有了这个,我们就可以在opengraph.xyz上进行测试。如果你做得很好,你应该看到像这样的东西。

Testing using a public URL

结论

我们已经看到了如何将元标签动态地添加到React应用程序中。当然,我作为一个例子,只是你可以应用这个解决方案的可能场景之一。你可以在我的GitHub上找到支持存储库

请注意,我写的后端代码只专注于添加元标签,以便在这篇文章中事情更加简单明了。如果你打算在生产中使用这个解决方案,请确保你至少添加基本的安全机制,以避免XSS和CSRF等漏洞。在Express网站上,你可以找到一整节专门讨论安全的最佳实践

The postAdding dynamic meta tags to a React app without SSRappeared first onLogRocket Blog.