2

站点访问 Pageviews 实现——相比于 Next.js 我更喜欢 Remix

 2 years ago
source link: https://segmentfault.com/a/1190000041223757
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

相比于 Next.js 我更喜欢 Remix

如题,本文以 Next.js 和 Remix 实现访问量统计为例。讲述我为什么更喜欢 Remix 框架。

Next.js Pageviews

首先以 Next.js 为例,其实在我的眼里, Next.js 更像是一个静态网站生成器。它与 Gatsby (另一个基于 React 的网站生成器)相比,门槛较低,可以快速上手。而 Gatsby 则需要一定的 GraphQL 基础。

废话不多说,开始正题。 页面访问点击这个功能肯定是需要数据存储的(数据库或者缓存、键值对存储等后端服务都可以作为替代)。

在我之前的文章里做过 Next.js 与 Remix 的对比——《网站的未来:Next.js 与 Remix》,Next.js 中 API 路由是需要防止在 pages/api/ 目录下的,而 Remix 就是路由,会更加灵活一些。

所以在 Next.js 实现的时候,需要先配置接口。这里我就先放一个比较有名的实际项目:

pages/api/views/[slug].ts 中完成接口的实现:

// https://github.com/leerob/leerob.io/blob/main/pages/api/views/%5Bslug%5D.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import prisma from 'lib/prisma';

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  try {
    const slug = req.query.slug.toString();

    if (req.method === 'POST') {
      const newOrUpdatedViews = await prisma.views.upsert({
        where: { slug },
        create: {
          slug
        },
        update: {
          count: {
            increment: 1
          }
        }
      });

      return res.status(200).json({
        total: newOrUpdatedViews.count.toString()
      });
    }

    if (req.method === 'GET') {
      const views = await prisma.views.findUnique({
        where: {
          slug
        }
      });

      return res.status(200).json({ total: views.count.toString() });
    }
  } catch (e) {
    return res.status(500).json({ message: e.message });
  }
}

GET 请求为查询, POST 请求为更新。

然后在页面中的话,需要使用 Fetch 库,如 swr 来进行调用:

// https://github.com/leerob/leerob.io/blob/main/components/ViewCounter.tsx
import { useEffect } from 'react';
import useSWR from 'swr';

import fetcher from 'lib/fetcher';
import { Views } from 'lib/types';

export default function ViewCounter({ slug }) {
  const { data } = useSWR<Views>(`/api/views/${slug}`, fetcher);
  const views = new Number(data?.total);

  useEffect(() => {
    const registerView = () =>
      fetch(`/api/views/${slug}`, {
        method: 'POST'
      });

    registerView();
  }, [slug]);

  return <span>{`${views > 0 ? views.toLocaleString() : '–––'} views`}</span>;
}

这里你会发现,页面在刚加载的时候,显示的文章阅读量是 ---- views,并且在后台里发了一个请求,完成后才会将文章计数更新到页面中展示出来。当我访问文章列表页面的时候,其实页面上发送了茫茫多的网络请求。

在前后端不分离的项目中实现前后端完全分离的代码,并通过 HTTP 的请求再去调用操作。这个操作就……一言难尽,反正性能并不高,对于 Google 搜索引擎收录来说或许影响不大,但是百度就肯定是无解的。

Remix Pageviews

因为 Remix 并不能提供 SSG (静态站点生成) 功能,所以前后端并不分离。这对于一些简单的动态需求的网站系统来说,就非常的友好,甚至在 Typescript 编写代码的时候,都不用特别去关注类型定义的问题。

app 目录中放置了项目的代码,routes 目录下自定义路由。比如我这个服务的实现,可以放在 app/services/views.server.ts ,其中,如果代码仅会跑在后端运行,可以用 .server.ts 的后缀进行区分。

这里的实现也相对会更简单一些,只需要两个方法即可,一个是写入数据计数,另一个是查询。在 Remix 中,我进行了一些设计思路上的优化,将写入计数和查询都在一次性完成。

因为是使用了 Cloudflare KV 存储(免费服务),所以实现起来有点像 Redis 的用法,总访问量的统计也需要一个键名单独计数。

// services/views.server.ts
// 定义返回值的类型, slug 表示地址 /slug 或者 total 表示总数
export interface PageView {
  slug: string;
  pv: number;
}

// 由于本地开发环境中,无法调用 Cloudflare Worker KV 绑定的存储桶,所以写了一个简单的 Mock 方法
const mockDb: KVNamespace = {
  // eslint-disable-next-line
  async get(...args: any[]) {
    return Promise.resolve('9999999');
  },
  // eslint-disable-next-line
  async put(...args: any[]) {
    return Promise.resolve();
  }
};

export class ViewsModel {
  db: KVNamespace;

  constructor(db?: KVNamespace) {
    this.db = db || mockDb;
  }

  // 方法一,用于对特定路径进行计数,并增加访问总数
  // 记录完成后,直接将数值作为 return 结果,减少了再次调用
  async visit(slug: string) {
    const [views, total] = await Promise.all(
      [slug, 'total'].map((key) => this.db.get(key, 'text'))
    );

    const pv = Number(views || 0) + 1;
    const pvTotal = Number(total || 0) + 1;

    await Promise.all([
      this.db.put(slug, pv.toString()),
      this.db.put('total', pvTotal.toString())
    ]);
    return [
      { slug, pv },
      { slug: 'total', pv: pvTotal }
    ] as PageView[];
  }

  // 专门为文章列表页准备的接口,可以批量查询文章访问量
  async list(slugs: string[]) {
    const result = await Promise.allSettled(
      slugs.map((slug) =>
        this.db
          .get(slug, 'text')
          .then((views) => ({ slug, pv: Number(views || 0) } as PageView))
      )
    );
    return result
      .filter((x) => x.status === 'fulfilled')
      .map((x: { value: PageView }) => x.value);
  }
}

然后查询的话根据需要,我将 visit 方法的使用放在了 root.tsx 下:

// app/root.tsx
import { ViewsModel,PageView } from './services/views.server';
// 忽略了其他的代码,只保留核心的部分

// 类型定义
export type CustomEnv = {
  VIEWS: KVNamespace;
};

export type AppContext = {
  env: CustomEnv;
};

export const loader: LoaderFunction = async ({ request, context = {} }) => {
  const { env = {} }: CustomEnv = context as AppContext;
  const url = new URL(request.url);
  const slug = url.pathname;
  // eslint-disable-next-line
  const PV = new ViewsModel(env.VIEWS);
  const views = await PV.visit(slug);

  const data: LoaderData = {
    views
  };

  return json(data);
};

代码中类型的定义和默认值的设置占了很大一部分,需要解释一下:因为我目前采用的方案是准备把网站放在 Cloudflare Pages 上(没💰搞服务器,所以使用的全是免费的方案),KV Namespace 存储方案在本地开发环境中无法调试。才有了这么多奇怪的打补丁一样的代码,忽略这一部分。只看核心内容:

  • 首先是通过 Request 的 URL 取出当前页面的 Slug,并进行计数。
  • 然后将结果返回给 loader 方法。

几行代码完成了接口的调用和数据的查询,该部分会随着页面路由的加载自动执行并将结果返回(我不太确定是否动态路由中每次路由改变都会触发,如果这里与我设想的不一致,后续我会回来修改这篇文章)。

然后在 App 中使用该数据即可:

// app/root.tsx
// 依然是刚才那个页面,部分代码

export type LoaderData = {
  views: PageView[]
};

function App() {
  const data = useLoaderData<LoaderData>();

  return (
    <html>
      <head>
        <meta charSet='utf-8' />
        <meta name='viewport' content='width=device-width,initial-scale=1' />
        <Meta />
        <Links />
      </head>
      <body>
        <div id='app' className='relative'>
          <div>
            <pre>{JSON.stringify(data, null, 2)}</pre>
          </div>
          <Outlet />
        </div>

        <ScrollRestoration />
        <Scripts />
        {process.env.NODE_ENV === 'development' && <LiveReload />}
      </body>
    </html>
  );
}

这里,data 就会返回这样类型的数据:

{
  views: [
    { slug: '/', pv: 99999},
    { slug: 'total', pv: 99999}
  ]
}

把数据传给组件或者状态管理中即可。同理,可以在 routes/blog.tsx 页面中加入 loader 方法,来获取页面上的文章列表,直接拼接每个文章的阅读量数据。

这样的框架在设计和编码的过程中,似乎更符合软件工程的高内聚、低耦合的思想。可以真正意义上的去实现模块化和微前端的开发。

目前我还在摸索中,可以持续关注我的 Remix 个人网站项目:


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK