将电子商务网站迁移到 Astro:我如何学会热爱 Headless 架构
去年我接手了一个看似简单的项目。将一个 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。逐步构建。
此外,如果你的环境变量包含特殊字符,为了每个人的理智,请用引号括起来。
相信我。