1

相比于 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 个人网站项目:


willin
213 声望12 粉丝

欢迎在各平台 Follow 我。