将电子商务网站迁移到 Astro:我如何学会热爱 Headless 架构

#astro #headless #e-commerce #woocommerce #tinacms

去年我接手了一个看似简单的项目。将一个 WordPress 护肤品网站转换为 Astro。客户是一位对其缓慢网站感到厌倦的企业主,在智利销售天然化妆品;没什么花哨的,只是优质产品。

我估计可能需要三周时间。最多四周。

两个月过去了。然而,这两个月教会我的比我作为自由职业者工作一年所学到的还要多。

WordPress 问题(与 WordPress 无关)

WooCommerce 和 WordPress 的事情是它们是功能性的。它们确实工作。这个技术栈为数百万每天产生收入的在线企业提供动力。因此,问题不在于它损坏了。

问题在于它的缓慢。以及难以编辑。此外,客户不断向我发送 PageSpeed 截图,显示令人沮丧的橙色分数 45。

每。该死的。周。“Jaime,这正常吗?”

不,这不正常。我不能告诉她,对于没有优化的 WordPress 网站来说,这实际上是相当常见的。

然而,速度并不是真正的痛苦。而是观察她与 WordPress 管理界面的斗争。她是一位创造出色护肤产品的企业家;她不是技术人员。为什么她需要理解 WordPress 块编辑器才能修改一个标题?

我曾经见过她。通过 Zoom。她想为促销更新主页横幅。她需要十五分钟。花十五分钟导航块,找到正确的页面,点击菜单,进行更改,预览(破坏了布局),修复布局,再次预览,然后发布。

她曾经无意中删除了整个产品部分。就这样…消失了。因为她不小心按了编辑器的 X 按钮。

我那时意识到我们需要采取不同的方法。

策略(我修改了三次)

最初我想,“好吧,让我们只是优化主题。” 清理一些代码,添加缓存,延迟加载图像,并考虑使用 CDN。典型的性能优化技术。

但后来我开始思考:如果我们不这样做呢?

如果我们利用 WordPress 的真正优势,而不是试图让它变快呢?WooCommerce 在跟踪订单、管理库存和处理付款方面非常出色。这相当于十五年经过验证的电子商务逻辑。我为什么要重建它?

然而,前端呢?用户看到的是什么?那可能是完全不同的东西。

Astro 在那时进入了画面。我听说过这种”部分水合”方法,其中 JavaScript 只为真正需要它的部分发送。无论如何,静态内容构成了电子商务网站的大部分。关于页面、博客条目和产品列表。对于基本上由文本和图像组成的页面,我们为什么要发送 200KB 的 JavaScript?

因此,策略变成了:

  • 保留 WooCommerce 用于库存和付款。
  • 使用 Astro 实现快速前端。
  • 包含 TinaCMS,以便客户可以进行可视化编辑。

老实说,TinaCMS 有点冒险。我从未将其应用于实际项目。然而,可视化编辑演示似乎正是客户所需要的。

一切崩溃的时间线

第 1 周:WooCommerce API 冒险

理论上,从 WooCommerce 将产品数据导入 Astro 似乎很容易。WooCommerce 有一个 REST API。Astro 能够检索数据。应该没问题,对吧?

旁白的声音:并不好。

是的,API 有文档。然而,有两种类型:那些有文档的和那些”实际上按你期望的方式工作的。” 这些不可互换。

为了可靠地检索产品,我花了两天时间创建 TypeScript 包装器。代码本身很简单:格式化智利比索价格并通过 ID 检索产品。但让它一致地工作?这需要一些时间。

// Fetch products by ID with type safety
const products = await getProductsByIds([123, 456, 789]);

// Format prices in local currency
formatPrice(21990); // Returns "$21.990"

// Generate add-to-cart URLs
getAddToCartUrl(productId);

管理失败是真正的挑战。因为当电子商务网站的 API 宕机而你没有准备时,用户会看到空白的产品页面。因此,没有销售。这表明客户会惊慌失措地打电话给你。

因此,我创建了一个备份计划。如果 API 由于任何原因失败,例如网络问题、凭据过期或 WooCommerce 决定小睡一下,网站会显示缓存的产品数据。即使价格有点过时,也比空白页面好得多。

export async function getProductsByIds(ids: number[]) {
  try {
    const response = await fetch(`${WC_URL}/wp-json/wc/v3/products`, {
      headers: {
        'Authorization': `Basic ${base64Credentials}`
      },
      params: { include: ids.join(',') }
    });

    return await response.json();
  } catch (error) {
    console.error('WooCommerce API error:', error);
    // Return cached/fallback product data
    return getFallbackProducts(ids);
  }
}

它完美吗?不。它能阻止凌晨三点的恐慌电话吗?确实。

第 3 周:购物车的故事

购物车…很有趣。

我想让它有现代感。你熟悉那些漂亮的电子商务网站,在那里向购物车添加商品是一个无缝的过程吗?只是一个漂亮的抽屉,显示你的商品,没有任何不稳定的行为或页面重载?我想要那样。

然而,仍然需要 WooCommerce 结账。因为付款,再一次。我不会重建支付系统。

我的解决方案——一个 React 购物车抽屉,在你浏览时将所有内容存储在 localStorage 中,然后将所有购物车数据编码到 URL 中并将你发送到 WordPress——可能不是我创建过的最优雅的东西,但它有效。

是的。URL 规范。用于购物车信息。

开发人员已经在喊叫了。“URL 不应该那么长!” 听着,它对 95% 的订单(1-3 件商品)正常工作。我们可能会对偶尔一次购买二十件商品的客户有问题。然而,那还没有发生。

https://example.com/?cart_data=PRODUCT_ID:QTY,PRODUCT_ID:QTY&auth_token=SIGNED_TOKEN

我为 WordPress 创建了一个插件,它接受这些 URL 参数,解析购物车内容,将它们添加到 WooCommerce 购物车,然后重定向到结账页面。

<?php
// WordPress plugin: Cart transfer handler
add_action('init', function() {
    if (isset($_GET['cart_data'])) {
        $items = explode(',', sanitize_text_field($_GET['cart_data']));

        // Clear existing cart
        WC()->cart->empty_cart();

        // Add all items
        foreach ($items as $item) {
            list($product_id, $quantity) = explode(':', $item);
            WC()->cart->add_to_cart(
                intval($product_id),
                intval($quantity)
            );
        }

        // Redirect to checkout
        wp_safe_redirect(wc_get_checkout_url());
        exit;
    }
});

用户看到他们的购物车已经被填充了。魔法。(客户不需要知道这是 hacky URL 参数;这不是魔法。)

第 4 周:SSO 灾难

用户不应该需要登录两次。那简直是糟糕的用户体验。当用户在 Astro 前端注册时,他们应该在到达 WooCommerce 结账时自动登录。

明显的答案似乎是 JWT 令牌。很简单:在 Astro 上创建签名令牌,将其发送到 WordPress,验证它,然后让他们登录。

除了一个星期四下午,两个小时内什么都不起作用。

令牌验证继续失败。我检查了一切。是的,签名算法是 SHA256。有效负载结构很好,包括时间戳和用户 ID。等等,密钥。

密钥中有一个井号。此外,井号之后的所有内容都在我的 .env 文件中被视为注释。

# Wrong - everything after # is ignored
JWT_SECRET=abc123#def456

# Correct - quoted to preserve special chars
JWT_SECRET="abc123#def456"

因此,实际上只使用了密钥的一半。使用错误的密钥对令牌进行签名。验证失败,当然。

调试花了整整两个小时。因为我没有用引号括起密钥。

这仍然让我恼火。

Astro 端的令牌生成:

import { createHmac } from 'crypto';

const generateSSOToken = (userId: string, secret: string): string => {
  const payload = {
    sub: userId,
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + 300 // 5 minutes
  };

  const signature = createHmac('sha256', secret)
    .update(JSON.stringify(payload))
    .digest('hex');

  return `${Buffer.from(JSON.stringify(payload)).toString('base64')}.${signature}`;
};

WordPress 验证令牌并验证用户:

<?php
// WordPress plugin: SSO token validation
function validate_sso_token($token) {
    list($payload_encoded, $signature) = explode('.', $token);

    $payload = json_decode(base64_decode($payload_encoded), true);

    // Verify signature
    $expected_signature = hash_hmac(
        'sha256',
        json_encode($payload),
        JWT_SECRET
    );

    if (!hash_equals($expected_signature, $signature)) {
        return false;
    }

    // Check expiration
    if ($payload['exp'] < time()) {
        return false;
    }

    // Log user in
    $user = get_user_by('id', $payload['sub']);
    wp_set_auth_cookie($user->ID);

    return true;
}

第 5-6 周:TinaCMS 学习曲线

TinaCMS 实现了其可视化编辑的承诺。然而,没有明显的方法可以到达那里。

Astro 组件是静态的,这是问题所在。它们在构建过程中只渲染一次。为了使实时编辑工作,TinaCMS 需要 React 组件。因此,我必须使用他们的 useTina 钩子将每个可编辑部分包装在 React 组件中。

// tina/pages/SectionWrapper.tsx
import { useTina, tinaField } from 'tinacms/dist/react';

export default function SectionWrapper({ query, variables, data }) {
  const { data: tinaData } = useTina({ query, variables, data });
  const section = tinaData?.section;

  return (
    <section data-tina-field={tinaField(section, 'title')}>
      <h2>{section.title}</h2>
      <div data-tina-field={tinaField(section, 'description')}>
        {section.description}
      </div>
    </section>
  );
}

然后在 Astro 页面中:

---
import SectionWrapper from '@/tina/pages/SectionWrapper';
import { client } from '@/tina/__generated__/client';

const { data, query, variables } = await client.queries.section({
  relativePath: 'section.json'
});
---

<SectionWrapper
  client:load
  {query}
  {variables}
  {data}
/>

一旦你掌握了模式,就不难了。然而,我花了比我愿意承认的更长时间来弄清楚这个模式。虽然仍然有学习曲线,但文档很好。

但现在呢?客户喜欢它。她有能力点击任何文本,直接在页面上编辑它,即时查看她的更改,然后点击保存。不再需要 WordPress 管理员。不再有意外删除部分。

学习曲线是值得的。

双语支持(简单的方法)

网站需要西班牙语和英语版本。我检查了 i18n 库。它们都似乎对于本质上”根据 URL 显示不同内容”来说过于复杂。

我决定通过为每种语言的内容创建不同的文件夹来保持简单。一个文件夹包含西班牙语博客条目,而另一个包含英语博客条目。单独的 JSON 文件被发送到页面。

content/
├── blog/           # Spanish posts
├── blog-en/        # English posts
└── pages/
    ├── home.json
    └── home-en.json

前缀 /en/ 应用于英语路由。就是这样。

// src/pages/blog/[slug].astro (Spanish)
// src/pages/en/blog/[slug].astro (English)

const { slug } = Astro.params;
const locale = Astro.url.pathname.startsWith('/en') ? 'en' : 'es';
const posts = await getCollection(locale === 'en' ? 'blog-en' : 'blog');

这是大公司使用的国际化策略吗?很可能不是。它对这个项目完美工作吗?当然。

直接的解决方案有时是最好的。

让它变快(有趣的部分)

你还记得那个 45 的 PageSpeed 分数吗?我们达到了 92。

这是真正改变了事情的:

自托管字体

Google Fonts 可以用自托管替代。这是最大的胜利。渲染被 Google Fonts 的外部请求阻止。通过切换到 Fontsource,它使用相同的字体但从我自己的域提供 WOFF2 文件,First Contentful Paint 减少了 1.4 秒。一个修改,巨大的效果。

// astro.config.mjs
import fontsource from '@fontsource/source-sans-pro';

export default defineConfig({
  // Fonts bundled at build time, no external requests
});

延迟加载视频

这个美丽的背景视频在主页上。最初是 4.3MB。我将其转换为 WebM(1.3MB),并且只在视口打开时加载它。当大多数移动用户甚至从不滚动足够远以看到它时,为什么要强制他们下载它?

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const video = entry.target as HTMLVideoElement;
      video.src = video.dataset.src!;
      video.load();
      observer.unobserve(video);
    }
  });
});

observer.observe(videoElement);

响应式图像

三种尺寸的响应式图像。移动设备上的图像宽 300 像素。平板电脑接收 600 像素。桌面接收 1200 像素。不是革命性的,但非常有效。

<img
  src={image.url}
  srcset={`
    ${image.url}?w=300 300w,
    ${image.url}?w=600 600w,
    ${image.url}?w=1200 1200w
  `}
  sizes="(max-width: 768px) 300px, (max-width: 1200px) 600px, 1200px"
  alt={image.alt}
/>

策略性预加载

<link rel="preload" as="image" href={heroImage} fetchpriority="high" />

代码分割是失败的优化。在意识到 Astro 的大多数页面根本不发送 JavaScript 之前,我尝试优化 JavaScript 包大小半天。我在做错误类型的优化。

无需训练轮的 SEO

Yoast 在 WordPress 迁移期间丢失了。这导致了自动模式生成的丢失。这需要手动完成所有事情。

博客条目、本地企业信息和组织详细信息的 JSON-LD 结构化数据。这很乏味但必不可少。如果你不给 Google 这些信息,你就会失去 SEO 分数。

---
const organizationSchema = {
  "@context": "https://schema.org",
  "@type": "Organization",
  "name": "Brand Name",
  "url": Astro.site,
  "logo": `${Astro.site}logo.png`,
  "sameAs": [
    "https://facebook.com/page",
    "https://instagram.com/account"
  ]
};

const localBusinessSchema = {
  "@context": "https://schema.org",
  "@type": "BeautySalon",
  "name": "Business Name",
  "address": {
    "@type": "PostalAddress",
    "streetAddress": "Street Address",
    "addressLocality": "City",
    "addressCountry": "CL"
  },
  "openingHours": "Mo-Fr 09:00-18:00"
};
---

<script type="application/ld+json" set:html={JSON.stringify(organizationSchema)} />
<script type="application/ld+json" set:html={JSON.stringify(localBusinessSchema)} />

对于博客文章:

---
const blogPostingSchema = {
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": post.title,
  "datePublished": post.date,
  "author": {
    "@type": "Person",
    "name": post.author
  },
  "image": post.image
};
---

因为 Astro 有一个插件可以自动管理站点地图,包括双语设置的 hreflang 标签,所以更简单。

// astro.config.mjs
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://example.com',
  integrations: [
    sitemap({
      i18n: {
        defaultLocale: 'es',
        locales: {
          es: 'es',
          en: 'en'
        }
      }
    })
  ]
});

安全性(没人谈论的东西)

限制登录尝试速率以阻止暴力攻击是安全性的一部分(没人谈论的东西)。指数退避最多三十秒。

const loginAttempts = new Map<string, { count: number, lastAttempt: number }>();

async function handleLogin(email: string, password: string) {
  const attempts = loginAttempts.get(email) || { count: 0, lastAttempt: 0 };

  // Exponential backoff: 2^attempts seconds
  const delay = Math.min(Math.pow(2, attempts.count) * 1000, 30000);
  const timeSinceLastAttempt = Date.now() - attempts.lastAttempt;

  if (timeSinceLastAttempt < delay) {
    throw new Error(`Too many attempts. Wait ${Math.ceil((delay - timeSinceLastAttempt) / 1000)}s`);
  }

  // Attempt login...
  attempts.count++;
  attempts.lastAttempt = Date.now();
  loginAttempts.set(email, attempts);
}

对于身份验证令牌,使用 HttpOnly cookie 防止 JavaScript 访问它们。为了阻止 CSRF,使用 SameSite=Strict。

export function setAuthCookie(token: string) {
  return new Response(null, {
    headers: {
      'Set-Cookie': `auth_token=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=86400`
    }
  });
}

启动时的环境变量验证。如果重要变量不存在,应用程序将不会启动。没有”可能没问题”的备份计划。

const requiredEnvVars = ['JWT_SECRET', 'WC_CONSUMER_KEY', 'WC_CONSUMER_SECRET'];

requiredEnvVars.forEach(varName => {
  if (!import.meta.env[varName]) {
    throw new Error(`Missing required environment variable: ${varName}`);
  }
});

因为凌晨两点发生生产事故并不愉快。

部署(简单的部分)

使用 Vercel 的部署几乎很无聊。当你推送到 main 时,它会自动部署。内容的更改由 TinaCMS 提交到 Git,然后启动重建和部署。

客户不知道这一切正在发生。在对内容进行更改并点击”保存”后,它会在几分钟内上线。事情应该是这样的。

为了维护 SEO,为所有过时的 WordPress URL 设置 301 重定向。博客文章必须被重定向,产品 URL 必须改变其结构。

{
  "redirects": [
    {
      "source": "/product/:slug",
      "destination": "/producto/:slug",
      "permanent": true
    },
    {
      "source": "/:slug",
      "destination": "/blog/:slug",
      "permanent": true
    }
  ]
}

我会做不同的事情

更早为购物车机制制定计划。我必须重构它两次,因为我没有意识到它会有多复杂。

留出更多时间来熟悉 TinaCMS。设置花费的时间比预期的要长,但一旦它工作,可视化编辑就很棒。

立即使用特殊字符测试环境变量。那个井号错误是可以避免的。

结果

在移动设备上,PageSpeed 从 45 增加到 92。

在不联系我的情况下,客户能够自己编辑内容。

付款仍然由 WooCommerce 可靠处理。

我获得了大量关于 headless 架构的知识。

我会重复这个行动吗?是的,很可能。然而,我会提高价格。

结论

Headless 不是关于利用最新、最先进的技术。为每项任务使用适当的工具至关重要。

WooCommerce 是一个出色的电子商务平台。保留它。

WordPress 管理员很笨重。更换它。

WordPress 主题很慢。更换它们。

你不必从头开始。你所要做的就是找出什么不起作用并替换它。

开始谦虚。启动 Astro。在单个部分中包含 TinaCMS。确定如何集成 WooCommerce。逐步构建。

此外,如果你的环境变量包含特殊字符,为了每个人的理智,请用引号括起来。

相信我。