基本介绍
- Headless:Shopify Headless 是一种将 Shopify 的后台电商功能与自定义前端分离的架构方式。传统的 Shopify 商店使用 Shopify 提供的模板和主题进行展示,而在 Headless 架构中,前端和后端是完全解耦的
- Hydrogen:Shopify Hydrogen 是 Shopify 推出的一个基于 React 的前端框架,专为构建 Shopify Headless 商店而设计。Hydrogen 提供了一套开发工具和组件库,让开发者能够快速构建高性能、定制化的电商体验,同时利用 Shopify 的后端服务。
- Shopify Oxygen:Oxygen 是为 Shopify 的定制店铺框架 Hydrogen 专门设计的托管平台。Hydrogen 是一个基于 React 的框架,用于构建自定义的、快速的 Shopify 店面,而 Oxygen 则是为其提供托管的基础设施。
- Graphql:一种 API 接口,用于更高效地与 Shopify 的数据进行交互。相比于传统的 REST API,GraphQL API 更加灵活和高效,允许客户端精确请求所需的数据,减少冗余的数据传输。
- Remix:Remix 是一个现代化的全栈 Web 框架,专注于构建快速、动态和高效的用户界面。它基于 React,并且以服务端渲染(SSR)为核心,旨在提供更好的用户体验和开发者体验。Remix 提供了一整套的工具和最佳实践,帮助开发者构建复杂的 Web 应用。
- Strapi:Strapi 是一个开源的 Headless CMS(内容管理系统),用于构建灵活、可扩展的 API 驱动应用程序。它允许开发者快速构建和管理内容,同时通过 REST 或 GraphQL API 将数据提供给前端。Strapi 提供了一个直观的管理面板,非技术用户也可以轻松地创建和管理内容。
- 优势
- 灵活性:前端可以完全定制,不受 Shopify 主题的限制,可以自定义URL路径
- 性能优化:使用现代前端框架可以提高网站的加载速度和 SEO 性能
- 更好的内容管理:可以将 Shopify 与其他 CMS(如 Strapi)集成,提供更好的内容管理能力
- 使用场景:定制化需求高的网站
项目介绍
目录结构
├─ .eslintignore
├─ .eslintrc.cjs
├─ .github // github CI/CD
│ └─ workflows
│ ├─ oxygen-deployment-1000022982.yml
│ └─ oxygen-deployment-1000023026.yml
├─ .gitignore
├─ .graphqlrc.js
├─ app
│ ├─ assets // 存放静态资源
│ │ └─ favicon.svg
│ ├─ components // 存放公共组件
│ │ ├─ AddToCartButton.jsx
│ │ ├─ Aside.jsx
│ │ ├─ CartLineItem.jsx
│ │ ├─ CartMain.jsx
│ │ ├─ CartSummary.jsx
│ │ ├─ Footer.jsx
│ │ ├─ Header.jsx
│ │ ├─ PageLayout.jsx
│ │ ├─ PaginatedResourceSection.jsx
│ │ ├─ ProductForm.jsx
│ │ ├─ ProductImage.jsx
│ │ ├─ ProductPrice.jsx
│ │ ├─ SearchForm.jsx
│ │ ├─ SearchFormPredictive.jsx
│ │ ├─ SearchResults.jsx
│ │ └─ SearchResultsPredictive.jsx
│ ├─ entry.client.jsx
│ ├─ entry.server.jsx
│ ├─ graphql
│ │ └─ customer-account
│ │ ├─ CustomerAddressMutations.js
│ │ ├─ CustomerDetailsQuery.js
│ │ ├─ CustomerOrderQuery.js
│ │ ├─ CustomerOrdersQuery.js
│ │ └─ CustomerUpdateMutation.js
│ ├─ lib // 封装的工具库
│ │ ├─ context.js
│ │ ├─ fragments.js
│ │ ├─ i18n.js
│ │ ├─ search.js
│ │ ├─ session.js
│ │ └─ variants.js
│ ├─ root.jsx // 根路径,页面入口
│ ├─ routes // 路由页面
│ │ ├─ ($locale).$.jsx
│ │ ├─ ($locale).account.$.jsx
│ │ ├─ ($locale).account.addresses.jsx
│ │ ├─ ($locale).account.jsx
│ │ ├─ ($locale).account.orders.$id.jsx
│ │ ├─ ($locale).account.orders._index.jsx
│ │ ├─ ($locale).account.profile.jsx
│ │ ├─ ($locale).account._index.jsx
│ │ ├─ ($locale).account_.authorize.jsx
│ │ ├─ ($locale).account_.login.jsx
│ │ ├─ ($locale).account_.logout.jsx
│ │ ├─ ($locale).blogs.$blogHandle.$articleHandle.jsx
│ │ ├─ ($locale).blogs.$blogHandle._index.jsx
│ │ ├─ ($locale).blogs._index.jsx
│ │ ├─ ($locale).cart.$lines.jsx
│ │ ├─ ($locale).cart.jsx
│ │ ├─ ($locale).collections.$handle.jsx
│ │ ├─ ($locale).collections.all.jsx
│ │ ├─ ($locale).collections._index.jsx
│ │ ├─ ($locale).discount.$code.jsx
│ │ ├─ ($locale).jsx
│ │ ├─ ($locale).pages.$handle.jsx
│ │ ├─ ($locale).policies.$handle.jsx
│ │ ├─ ($locale).policies._index.jsx
│ │ ├─ ($locale).products.$handle.jsx
│ │ ├─ ($locale).search.jsx
│ │ ├─ ($locale).[sitemap.xml].jsx
│ │ ├─ ($locale)._index.jsx
│ │ ├─ api.quick-order.jsx
│ │ ├─ api.subscribe.jsx
│ │ └─ [robots.txt].jsx
│ └─ styles // 样式
│ ├─ app.css
│ └─ reset.css
├─ CHANGELOG.md
├─ customer-accountapi.generated.d.ts
├─ env.d.ts
├─ jsconfig.json
├─ package-lock.json
├─ package.json
├─ public
│ └─ .gitkeep
├─ README.md
├─ server.js
├─ storefrontapi.generated.d.ts
└─ vite.config.js // vite相关配置
assets
文件夹存放一些静态资源(image、svg、media等等)components
文件夹存放封装的公共组件lib
文件夹存放封装好的工具库或者Hooksroutes
文件夹下是存放相关路由,也就是对应相关的url路径,相关资料(路由文件命名)($locale)
是占位符并且可选,用于多语言的方案切换,例如 /zh-cn/products、/en-us/products- 每次新增一个新的路由,都需要在
robots.txt
中去新增可返回的路径名
开发流程
确定整体的开发框架
多语言
利用脚手架已经写好的项目逻辑,可以看到
定义:
使用:(拿到相关信息)
const {language, country} = context.storefront.i18n;
使用Scss预处理器
- 安装依赖
npm install sass -D
- 在
vite.config.js
中修改css相关配置
css: {
preprocessorOptions: {
scss: {
additionalData: `@import "~/styles/global.scss";`,
},
},
devSourcemap: true,
},
- 在组件中使用Module CSS
import React, {useState} from 'react';
import {motion, AnimatePresence} from 'framer-motion';
import styles from './index.module.scss';
// 折叠面板公共组件
/**
* @param {string | VNode} title - 标题
* @param {VNode} children - 标题
*/
const Accordion = ({title, children}) => {
const [isOpen, setIsOpen] = useState(false);
const togglePanel = () => {
setIsOpen(!isOpen);
};
return (
<div className={styles.accordion}>
<div onClick={togglePanel} className={styles.header}>
{typeof title === 'string' ? (
<div className={styles.title}>{title}</div>
) : (
title
)}
<div className={styles.icon}>
{isOpen ? <span className={styles.iconOpen}>-</span> : '+'}
</div>
</div>
<AnimatePresence>
<motion.div
initial={{height: 0, opacity: 0}}
animate={{height: isOpen ? 'auto' : 0, opacity: isOpen ? 1 : 0}}
exit={{height: 0, opacity: 0}}
transition={{duration: 0.3}}
style={{overflow: 'hidden'}}
>
<div className={styles.content}>{children}</div>
</motion.div>
</AnimatePresence>
</div>
);
};
export default Accordion;
- scss文件名需要定义为xxx.module.css(相对与Vue的scoped,具有样式隔离的作用)
.accordion {
border-bottom: 1px solid #eaeaea;
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 0;
// border-bottom: 1px solid #eaeaea;
cursor: pointer;
@media (width <= 768px) {
padding-block: 10px;
}
.title {
width: calc(100% - 54px);
margin: 0;
font-size: 18px;
font-family: MontserratMedium, sans-serif;
@media (width <= 768px) {
font-size: 16px;
line-height: 1.6;
}
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
font-size: 32px;
.iconOpen {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 44px;
@media (width <= 768px) {
font-size: 34px;
}
}
@media (width <= 768px) {
font-size: 24px;
}
}
}
.content {
padding: 0 0 16px;
background-color: #f7f9f9;
white-space: pre-wrap;
word-wrap: break-word; /* 旧名称 */
overflow-wrap: break-word; /* 新名称 */
p {
font-size: 14px;
line-height: 1.5;
}
}
}
.accordion:last-child {
.header {
border-bottom: none;
}
}
Axios的二次封装
- 在
app
目录下创建http
的文件夹,在http
文件夹下创建request.js
文件,二次封装好对RESTFUL API接口
import axios from 'axios';
// BASE_URL
const BASE_URL = 'https://strapi.wininfluencer.com/api';
// 请求头 - Authorization
const AUTHORIZATION =
'Bearer 733e5129c6ce52de6a628973c8247f68ff64a2d4655af6dbfab4cf3e32518c4411f1c8249afe7bdcd14ed07994a6f59a22ba088ec9fcc557b72a5c856d1d7b762fb43f67fe5059cd286b067adf7ff1fe6c73de3b9dd8e957478326cc62478e6320b7209388d7dd41e87826eecfced54d9de7dca30717a840dc361154b6c80641';
const service = axios.create({
timeout: 60 * 1000,
baseURL: BASE_URL,
headers: {
'Content-Type': 'application/json',
Authorization: AUTHORIZATION,
},
});
// axios实例拦截请求
service.interceptors.request.use(
(config) => {
// 这里可以对config请求体数据进行处理
return config;
},
(error) => {
// 排除错误
return Promise.reject(error);
},
);
// axios实例拦截响应
service.interceptors.response.use(
(response) => {
return response.data;
},
// 请求失败
(error) => {
return Promise.reject(error);
},
);
// 此处相当于二次响应拦截
// 为响应数据进行定制化处理
const requestInstance = (config) => {
return new Promise((resolve, reject) => {
service
.request(config)
.then((data) => {
// 成功直接返回数据
if (data.result === 'success') {
config.returnAllData ? resolve(data) : resolve(data.data);
} else {
config.returnAllData ? resolve(data) : resolve(data.data);
}
})
.catch((error) => {
reject(error);
});
});
};
// GET请求
export function get(url, parms, config = {}) {
return requestInstance({
url,
method: 'GET',
params: parms,
...config,
});
}
// POST请求
export function post(url, data, config = {}) {
return requestInstance({
url,
method: 'POST',
data,
...config,
});
}
// PUT请求
export function put(url, data, config = {}) {
return requestInstance({
url,
method: 'PUT',
data,
...config,
});
}
// DELETE请求
export function del(url, data, config = {}) {
return requestInstance({
url,
method: 'DELETE',
data,
...config,
});
}
- 在
http
文件夹下创建project.js
文件,用于某一页面接口的的集合管理,将函数接口暴露出去提供使用
import {get} from '~/http/request';
/**
* 解决方案接口
*/
const services = {
// 页面
getProjectPage(params) {
return get(
'/project-page',
{
populate: '*',
...params,
},
{},
);
},
// 页面
getProjectDetailPage(params) {
return get(
'/project-detail-page',
{
populate: '*',
...params,
},
{},
);
},
// 获取一级解决方案
getProjectCategories(params) {
return get(
'/project-categories',
{
populate: '*',
...params,
},
{},
);
},
// 获取所有的解决方案
getAllProject(params) {
return get(
'/projects',
{
populate:
'meta,banner_module,banner_module.content,impact_module,product_module,other_projects_module,summaryt_module,project_categories',
'pagination[pageSize]': '8',
...params,
},
{returnAllData: true},
);
},
};
export default services;
路由确定
- 需要与设计进行需求对齐
- 有哪些页面(并定好页面的url的命名),例如联系我们页面(/contact-us)
- 每个页面要跳转的页面url
- 确定是否需要多语言
- 需要则对每个路由页面都进行判断切换语言
例如页面已经确定了有首页、集合页、关于我们、联系我们这四个页面
在routes
页面理应只有($locale)._index.jsx
、($locale).collection._index.jsx
、($locale).about-us._index.jsx
、($locale).contact_index.jsx
这四个路由页面,因为模板有自带的其他页面参考,可以把其他的页面删除掉,因为会占到相关的内存,每个Shopify oxygen提供的托管服务器只能提供最多10M内存大小的资源,所以尽可能减少无关页面的存在。
接口数据结构和字段确定
- 对齐好需求后,应该先将需求理清,然后将在Shopify后台或者Srtapi后台开始可以配一些数据和定义数据结构
- 这里分GraphQL Storefront API 和 Strapi API
- GraphQL Storefront API的数据结构以及字段都是已经由Shopify确定的,所以我们就根据提供的结果进行数据转换和展示。
- Strapi API是先由开发者自己先定义然后再去调用接口的,所以我们需要先确定数据结构和字段,后续在使用接口的时候不应该轻易去改变结构,按照提供的数据结构来进行开发。
静态页面开发
公共组件开发
- 组件封装需写清楚各个入参的描述信息,以及组件的使用
import React, {useState} from 'react';
import {motion, AnimatePresence} from 'framer-motion';
import styles from './index.module.scss';
// 折叠面板公共组件
/**
* @param {string | VNode} title - 标题
* @param {VNode} children - 标题
*/
const Accordion = ({title, children}) => {
const [isOpen, setIsOpen] = useState(false);
const togglePanel = () => {
setIsOpen(!isOpen);
};
return (
<div className={styles.accordion}>
<div onClick={togglePanel} className={styles.header}>
{typeof title === 'string' ? (
<div className={styles.title}>{title}</div>
) : (
title
)}
<div className={styles.icon}>
{isOpen ? <span className={styles.iconOpen}>-</span> : '+'}
</div>
</div>
<AnimatePresence>
<motion.div
initial={{height: 0, opacity: 0}}
animate={{height: isOpen ? 'auto' : 0, opacity: isOpen ? 1 : 0}}
exit={{height: 0, opacity: 0}}
transition={{duration: 0.3}}
style={{overflow: 'hidden'}}
>
<div className={styles.content}>{children}</div>
</motion.div>
</AnimatePresence>
</div>
);
};
export default Accordion;
路由页面开发
- 一般在开发中会用到以下的函数
- action
- 可以理解为请求post的接口后的操作
- route-component(default)
- 渲染呈现的UI界面
- ErrorBoundary
- 页面出现报错时展示的元素
- links
- 页面添加的元素
- loader
- 相当于Get请求的操作
- meta
- 在页面上的元标签
- action
总结
Strapi的使用
在项目里使用
- 一般读取数据会在
loader
函数中进行操作
import httpGeneral from '~/http/general';
export async function loader(args) {
// Start fetching non-critical data without blocking time to first byte
const deferredData = loadDeferredData(args);
// Await the critical data required to render initial state of the page
const criticalData = await loadCriticalData(args);
// const data = await HttpQuote.getQuote({key: 'value'});
const data = await httpGeneral.getGeneral({key: 'value'});
// 确保 criticalData.product.metafields 是一个有效的数组
const metafields = Array.isArray(criticalData.product.metafields)
? criticalData.product.metafields
: [];
// 查找 related_products 字段,确保每个项都是对象并且具有 key 属性
const relatedProducts =
metafields.find((item) => item && item.key === 'related_products') || null;
const relatedHandle = relatedProducts?.reference?.handle;
const recommendedData = await loadRecommendedData({
context: args.context,
relatedHandle,
});
const {locale} = args.params;
return defer({
...deferredData,
...criticalData,
...recommendedData,
locale,
data,
// pageData,
});
}
- 然后在组件中去使用
useLoaderData
函数去获取
import {useLoaderData} from '@remix-run/react';
export default function Blogs() {
/** @type {LoaderReturnData} */
const {blogs} = useLoaderData();
return (
<div className="blogs">
<h1>Blogs</h1>
<div className="blogs-grid">
<Pagination connection={blogs}>
{({nodes, isLoading, PreviousLink, NextLink}) => {
return (
<>
<PreviousLink>
{isLoading ? 'Loading...' : <span>↑ Load previous</span>}
</PreviousLink>
{nodes.map((blog) => {
return (
<Link
className="blog"
key={blog.handle}
prefetch="intent"
to={`/blogs/${blog.handle}`}
>
<h2>{blog.title}</h2>
</Link>
);
})}
<NextLink>
{isLoading ? 'Loading...' : <span>Load more ↓</span>}
</NextLink>
</>
);
}}
</Pagination>
</div>
</div>
);
}
常用的Components、Hooks、Utilites
Components - Hydrogen
- 购物车组件,一般使用在结账逻辑、商品添加、购物车页面逻辑,不必再自己造轮子封装逻辑。
参考:https://shopify.dev/docs/api/hydrogen/2023-07/components/cartform
import { CartForm } from '@shopify/hydrogen';
- 媒体组件(ExternalVideo、Image、MediaFile、ModelViewer、Video)
参考:https://shopify.dev/docs/api/hydrogen-react/2024-07/components/media/externalvideo
import {Image,ExternalVideo,MediaFile,ModelViewer,Video} from '@shopify/hydrogen';
Components - Remix
- Await - https://remix.run/docs/en/main/components/await
- Form - https://remix.run/docs/en/main/components/form
- Link - https://remix.run/docs/en/main/components/link
import { Await, Form, Link, useRouteLoaderData } from "@remix-run/react";
Hooks - Hydrogen
- useCart - https://shopify.dev/docs/api/hydrogen-react/2024-07/hooks/usecart
- useShop - https://shopify.dev/docs/api/hydrogen-react/2024-07/hooks/useshop
import { useCart, useShop } from "@remix-run/react";
Hooks - Remix
- useloaderdata - https://remix.run/docs/en/main/hooks/use-loader-data#useloaderdata
- useLocation- https://remix.run/docs/en/main/hooks/use-location
- useNavigate- https://remix.run/docs/en/main/hooks/use-navigate
- useNavigation- https://remix.run/docs/en/main/hooks/use-navigation
- useParams - https://remix.run/docs/en/main/hooks/use-params
- useRouteLoaderData - https://remix.run/docs/en/main/hooks/use-route-loader-data
import { useNavigate, useLocation, useNavigate, useNavigation, useParam, useRouteLoaderData } from "@remix-run/react";
Utilites
- json - https://remix.run/docs/en/main/utils/json
- defer - https://remix.run/docs/en/main/utils/defer
- redirect - https://remix.run/docs/en/main/utils/json
import { json, defer ,redirect } from '@shopify/remix-oxygen';
第三方库的使用(推荐)
NPM包 | 功能 | 官网 |
---|---|---|
ahooks | 提供了丰富的、可复用的 React Hooks | https://ahooks.pages.dev/ |
react-intersection-observer | 通常用于懒加载图片、动画触发、无尽滚动、广告曝光监控等场景 | https://react-intersection-observer.vercel.app/?path=/docs/intro—docs |
swiper、swiper/react | 轮播图效果 | https://swiperjs.com/react |
framer-motion | 强大且易用的 React 动画库,专门用于创建复杂而流畅的动画和交互效果 | https://www.framer.com/motion/ |
开发注意事项
- 在安装引用了第三方库时,有时候会报错,
require is not define
等等类似的错误,这时候应该在vite.config.js
文件中写入相关配置
ssr: {
optimizeDeps: {
/**
* Include dependencies here if they throw CJS<>ESM errors.
* For example, for the following error:
*
* > ReferenceError: module is not defined
* > at /Users/.../node_modules/example-dep/index.js:1:1
*
* Include 'example-dep' in the array below.
* @see https://vitejs.dev/config/dep-optimization-options
*/
include: ['这里填写你的引用依赖值'],
},
},
- 请求读取Shopify的数据时,会有一个缓存策略,https://shopify.dev/docs/storefronts/headless/hydrogen/caching
const {nonce, header, NonceProvider} = createContentSecurityPolicy({
styleSrc: [
"'self'",
'https://cdn.shopify.com',
'https://some-custom-css.cdn',
],
});
- 使用vite-plugin-svgr插件
- 安装依赖包
npm i -D vite-plugin-svgr
import svgr from 'vite-plugin-svgr';
plugins: [
hydrogen(),
oxygen(),
remix({
presets: [hydrogen.preset()],
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
},
}),
tsconfigPaths(),
svgr(),
],
- 使用
import HomepageIconSvg2 from '~/assets/svg/homepage-icon-2.svg?react';
const SolutionLearnMoreCard = ({
}) => {
return (
<div className={styles.solution_learn_more_card}>
{/* 背景图片 */}
<img src={imageSrc} alt={imageAlt} />
{/* 文案 */}
<div className={styles.text_container}>
<AnimationText>
<div className={styles.text}>
<div className={styles.title}>{mainTitle}</div>
<div className={styles.desc}>{description}</div>
<Link
className={styles.more}
to={handle ? `/${handle}${link}` : link}
>
<span className={styles.more_text}>{button}</span>
<RightArrowSvg />
</Link>
</div>
</AnimationText>
</div>
</div>
);
};
参考链接
- Headless - shopify.dev
- Hydrogen React - shopify.dev
- GraphQL Storefront API - shopify.dev
- Hydrogen - shopify.dev
- Remix - Docs
- Strapi - Docs
- Shopify Hydrogen + Strapi开发方案
- Shopify Hydrogen + 获取Shopify内容
- Shopify Hydrogen 重定向