基本介绍
- 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.divinitial={{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_URLconst BASE_URL = 'https://strapi.wininfluencer.com/api';// 请求头 - Authorizationconst 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.divinitial={{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 byteconst deferredData = loadDeferredData(args);// Await the critical data required to render initial state of the pageconst 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 (<LinkclassName="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><LinkclassName={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 重定向
