React + Remix 前端代码开发规范工程化 - 图1

基本介绍

  • 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)集成,提供更好的内容管理能力
  • 使用场景:定制化需求高的网站

项目介绍

目录结构

  1. ├─ .eslintignore
  2. ├─ .eslintrc.cjs
  3. ├─ .github // github CI/CD
  4. └─ workflows
  5. ├─ oxygen-deployment-1000022982.yml
  6. └─ oxygen-deployment-1000023026.yml
  7. ├─ .gitignore
  8. ├─ .graphqlrc.js
  9. ├─ app
  10. ├─ assets // 存放静态资源
  11. └─ favicon.svg
  12. ├─ components // 存放公共组件
  13. ├─ AddToCartButton.jsx
  14. ├─ Aside.jsx
  15. ├─ CartLineItem.jsx
  16. ├─ CartMain.jsx
  17. ├─ CartSummary.jsx
  18. ├─ Footer.jsx
  19. ├─ Header.jsx
  20. ├─ PageLayout.jsx
  21. ├─ PaginatedResourceSection.jsx
  22. ├─ ProductForm.jsx
  23. ├─ ProductImage.jsx
  24. ├─ ProductPrice.jsx
  25. ├─ SearchForm.jsx
  26. ├─ SearchFormPredictive.jsx
  27. ├─ SearchResults.jsx
  28. └─ SearchResultsPredictive.jsx
  29. ├─ entry.client.jsx
  30. ├─ entry.server.jsx
  31. ├─ graphql
  32. └─ customer-account
  33. ├─ CustomerAddressMutations.js
  34. ├─ CustomerDetailsQuery.js
  35. ├─ CustomerOrderQuery.js
  36. ├─ CustomerOrdersQuery.js
  37. └─ CustomerUpdateMutation.js
  38. ├─ lib // 封装的工具库
  39. ├─ context.js
  40. ├─ fragments.js
  41. ├─ i18n.js
  42. ├─ search.js
  43. ├─ session.js
  44. └─ variants.js
  45. ├─ root.jsx // 根路径,页面入口
  46. ├─ routes // 路由页面
  47. ├─ ($locale).$.jsx
  48. ├─ ($locale).account.$.jsx
  49. ├─ ($locale).account.addresses.jsx
  50. ├─ ($locale).account.jsx
  51. ├─ ($locale).account.orders.$id.jsx
  52. ├─ ($locale).account.orders._index.jsx
  53. ├─ ($locale).account.profile.jsx
  54. ├─ ($locale).account._index.jsx
  55. ├─ ($locale).account_.authorize.jsx
  56. ├─ ($locale).account_.login.jsx
  57. ├─ ($locale).account_.logout.jsx
  58. ├─ ($locale).blogs.$blogHandle.$articleHandle.jsx
  59. ├─ ($locale).blogs.$blogHandle._index.jsx
  60. ├─ ($locale).blogs._index.jsx
  61. ├─ ($locale).cart.$lines.jsx
  62. ├─ ($locale).cart.jsx
  63. ├─ ($locale).collections.$handle.jsx
  64. ├─ ($locale).collections.all.jsx
  65. ├─ ($locale).collections._index.jsx
  66. ├─ ($locale).discount.$code.jsx
  67. ├─ ($locale).jsx
  68. ├─ ($locale).pages.$handle.jsx
  69. ├─ ($locale).policies.$handle.jsx
  70. ├─ ($locale).policies._index.jsx
  71. ├─ ($locale).products.$handle.jsx
  72. ├─ ($locale).search.jsx
  73. ├─ ($locale).[sitemap.xml].jsx
  74. ├─ ($locale)._index.jsx
  75. ├─ api.quick-order.jsx
  76. ├─ api.subscribe.jsx
  77. └─ [robots.txt].jsx
  78. └─ styles // 样式
  79. ├─ app.css
  80. └─ reset.css
  81. ├─ CHANGELOG.md
  82. ├─ customer-accountapi.generated.d.ts
  83. ├─ env.d.ts
  84. ├─ jsconfig.json
  85. ├─ package-lock.json
  86. ├─ package.json
  87. ├─ public
  88. └─ .gitkeep
  89. ├─ README.md
  90. ├─ server.js
  91. ├─ storefrontapi.generated.d.ts
  92. └─ vite.config.js // vite相关配置
  • assets文件夹存放一些静态资源(image、svg、media等等)
  • components文件夹存放封装的公共组件
  • lib文件夹存放封装好的工具库或者Hooks
  • routes文件夹下是存放相关路由,也就是对应相关的url路径,相关资料(路由文件命名
    • ($locale)是占位符并且可选,用于多语言的方案切换,例如 /zh-cn/products、/en-us/products
    • 每次新增一个新的路由,都需要在robots.txt中去新增可返回的路径名

开发流程

确定整体的开发框架

多语言

利用脚手架已经写好的项目逻辑,可以看到

定义

React + Remix 前端代码开发规范工程化 - 图2React + Remix 前端代码开发规范工程化 - 图3

使用:(拿到相关信息)

  1. const {language, country} = context.storefront.i18n;

React + Remix 前端代码开发规范工程化 - 图4

使用Scss预处理器

  • 安装依赖
  1. npm install sass -D
  • vite.config.js中修改css相关配置
  1. css: {
  2. preprocessorOptions: {
  3. scss: {
  4. additionalData: `@import "~/styles/global.scss";`,
  5. },
  6. },
  7. devSourcemap: true,
  8. },
  • 在组件中使用Module CSS

React + Remix 前端代码开发规范工程化 - 图5

  1. import React, {useState} from 'react';
  2. import {motion, AnimatePresence} from 'framer-motion';
  3. import styles from './index.module.scss';
  4. // 折叠面板公共组件
  5. /**
  6. * @param {string | VNode} title - 标题
  7. * @param {VNode} children - 标题
  8. */
  9. const Accordion = ({title, children}) => {
  10. const [isOpen, setIsOpen] = useState(false);
  11. const togglePanel = () => {
  12. setIsOpen(!isOpen);
  13. };
  14. return (
  15. <div className={styles.accordion}>
  16. <div onClick={togglePanel} className={styles.header}>
  17. {typeof title === 'string' ? (
  18. <div className={styles.title}>{title}</div>
  19. ) : (
  20. title
  21. )}
  22. <div className={styles.icon}>
  23. {isOpen ? <span className={styles.iconOpen}>-</span> : '+'}
  24. </div>
  25. </div>
  26. <AnimatePresence>
  27. <motion.div
  28. initial={{height: 0, opacity: 0}}
  29. animate={{height: isOpen ? 'auto' : 0, opacity: isOpen ? 1 : 0}}
  30. exit={{height: 0, opacity: 0}}
  31. transition={{duration: 0.3}}
  32. style={{overflow: 'hidden'}}
  33. >
  34. <div className={styles.content}>{children}</div>
  35. </motion.div>
  36. </AnimatePresence>
  37. </div>
  38. );
  39. };
  40. export default Accordion;
  • scss文件名需要定义为xxx.module.css(相对与Vue的scoped,具有样式隔离的作用)
  1. .accordion {
  2. border-bottom: 1px solid #eaeaea;
  3. .header {
  4. display: flex;
  5. align-items: center;
  6. justify-content: space-between;
  7. padding: 24px 0;
  8. // border-bottom: 1px solid #eaeaea;
  9. cursor: pointer;
  10. @media (width <= 768px) {
  11. padding-block: 10px;
  12. }
  13. .title {
  14. width: calc(100% - 54px);
  15. margin: 0;
  16. font-size: 18px;
  17. font-family: MontserratMedium, sans-serif;
  18. @media (width <= 768px) {
  19. font-size: 16px;
  20. line-height: 1.6;
  21. }
  22. }
  23. .icon {
  24. display: flex;
  25. align-items: center;
  26. justify-content: center;
  27. width: 32px;
  28. height: 32px;
  29. font-size: 32px;
  30. .iconOpen {
  31. display: flex;
  32. align-items: center;
  33. justify-content: center;
  34. width: 100%;
  35. height: 100%;
  36. font-size: 44px;
  37. @media (width <= 768px) {
  38. font-size: 34px;
  39. }
  40. }
  41. @media (width <= 768px) {
  42. font-size: 24px;
  43. }
  44. }
  45. }
  46. .content {
  47. padding: 0 0 16px;
  48. background-color: #f7f9f9;
  49. white-space: pre-wrap;
  50. word-wrap: break-word; /* 旧名称 */
  51. overflow-wrap: break-word; /* 新名称 */
  52. p {
  53. font-size: 14px;
  54. line-height: 1.5;
  55. }
  56. }
  57. }
  58. .accordion:last-child {
  59. .header {
  60. border-bottom: none;
  61. }
  62. }

Axios的二次封装

React + Remix 前端代码开发规范工程化 - 图6

  • app目录下创建http的文件夹,在http文件夹下创建request.js文件,二次封装好对RESTFUL API接口
  1. import axios from 'axios';
  2. // BASE_URL
  3. const BASE_URL = 'https://strapi.wininfluencer.com/api';
  4. // 请求头 - Authorization
  5. const AUTHORIZATION =
  6. 'Bearer 733e5129c6ce52de6a628973c8247f68ff64a2d4655af6dbfab4cf3e32518c4411f1c8249afe7bdcd14ed07994a6f59a22ba088ec9fcc557b72a5c856d1d7b762fb43f67fe5059cd286b067adf7ff1fe6c73de3b9dd8e957478326cc62478e6320b7209388d7dd41e87826eecfced54d9de7dca30717a840dc361154b6c80641';
  7. const service = axios.create({
  8. timeout: 60 * 1000,
  9. baseURL: BASE_URL,
  10. headers: {
  11. 'Content-Type': 'application/json',
  12. Authorization: AUTHORIZATION,
  13. },
  14. });
  15. // axios实例拦截请求
  16. service.interceptors.request.use(
  17. (config) => {
  18. // 这里可以对config请求体数据进行处理
  19. return config;
  20. },
  21. (error) => {
  22. // 排除错误
  23. return Promise.reject(error);
  24. },
  25. );
  26. // axios实例拦截响应
  27. service.interceptors.response.use(
  28. (response) => {
  29. return response.data;
  30. },
  31. // 请求失败
  32. (error) => {
  33. return Promise.reject(error);
  34. },
  35. );
  36. // 此处相当于二次响应拦截
  37. // 为响应数据进行定制化处理
  38. const requestInstance = (config) => {
  39. return new Promise((resolve, reject) => {
  40. service
  41. .request(config)
  42. .then((data) => {
  43. // 成功直接返回数据
  44. if (data.result === 'success') {
  45. config.returnAllData ? resolve(data) : resolve(data.data);
  46. } else {
  47. config.returnAllData ? resolve(data) : resolve(data.data);
  48. }
  49. })
  50. .catch((error) => {
  51. reject(error);
  52. });
  53. });
  54. };
  55. // GET请求
  56. export function get(url, parms, config = {}) {
  57. return requestInstance({
  58. url,
  59. method: 'GET',
  60. params: parms,
  61. ...config,
  62. });
  63. }
  64. // POST请求
  65. export function post(url, data, config = {}) {
  66. return requestInstance({
  67. url,
  68. method: 'POST',
  69. data,
  70. ...config,
  71. });
  72. }
  73. // PUT请求
  74. export function put(url, data, config = {}) {
  75. return requestInstance({
  76. url,
  77. method: 'PUT',
  78. data,
  79. ...config,
  80. });
  81. }
  82. // DELETE请求
  83. export function del(url, data, config = {}) {
  84. return requestInstance({
  85. url,
  86. method: 'DELETE',
  87. data,
  88. ...config,
  89. });
  90. }
  • http文件夹下创建project.js文件,用于某一页面接口的的集合管理,将函数接口暴露出去提供使用
  1. import {get} from '~/http/request';
  2. /**
  3. * 解决方案接口
  4. */
  5. const services = {
  6. // 页面
  7. getProjectPage(params) {
  8. return get(
  9. '/project-page',
  10. {
  11. populate: '*',
  12. ...params,
  13. },
  14. {},
  15. );
  16. },
  17. // 页面
  18. getProjectDetailPage(params) {
  19. return get(
  20. '/project-detail-page',
  21. {
  22. populate: '*',
  23. ...params,
  24. },
  25. {},
  26. );
  27. },
  28. // 获取一级解决方案
  29. getProjectCategories(params) {
  30. return get(
  31. '/project-categories',
  32. {
  33. populate: '*',
  34. ...params,
  35. },
  36. {},
  37. );
  38. },
  39. // 获取所有的解决方案
  40. getAllProject(params) {
  41. return get(
  42. '/projects',
  43. {
  44. populate:
  45. 'meta,banner_module,banner_module.content,impact_module,product_module,other_projects_module,summaryt_module,project_categories',
  46. 'pagination[pageSize]': '8',
  47. ...params,
  48. },
  49. {returnAllData: true},
  50. );
  51. },
  52. };
  53. 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是先由开发者自己先定义然后再去调用接口的,所以我们需要先确定数据结构和字段,后续在使用接口的时候不应该轻易去改变结构,按照提供的数据结构来进行开发。

静态页面开发

公共组件开发

  • 组件封装需写清楚各个入参的描述信息,以及组件的使用
  1. import React, {useState} from 'react';
  2. import {motion, AnimatePresence} from 'framer-motion';
  3. import styles from './index.module.scss';
  4. // 折叠面板公共组件
  5. /**
  6. * @param {string | VNode} title - 标题
  7. * @param {VNode} children - 标题
  8. */
  9. const Accordion = ({title, children}) => {
  10. const [isOpen, setIsOpen] = useState(false);
  11. const togglePanel = () => {
  12. setIsOpen(!isOpen);
  13. };
  14. return (
  15. <div className={styles.accordion}>
  16. <div onClick={togglePanel} className={styles.header}>
  17. {typeof title === 'string' ? (
  18. <div className={styles.title}>{title}</div>
  19. ) : (
  20. title
  21. )}
  22. <div className={styles.icon}>
  23. {isOpen ? <span className={styles.iconOpen}>-</span> : '+'}
  24. </div>
  25. </div>
  26. <AnimatePresence>
  27. <motion.div
  28. initial={{height: 0, opacity: 0}}
  29. animate={{height: isOpen ? 'auto' : 0, opacity: isOpen ? 1 : 0}}
  30. exit={{height: 0, opacity: 0}}
  31. transition={{duration: 0.3}}
  32. style={{overflow: 'hidden'}}
  33. >
  34. <div className={styles.content}>{children}</div>
  35. </motion.div>
  36. </AnimatePresence>
  37. </div>
  38. );
  39. };
  40. export default Accordion;

路由页面开发

总结

React + Remix 前端代码开发规范工程化 - 图7

React + Remix 前端代码开发规范工程化 - 图8

Strapi的使用

在项目里使用

  • 一般读取数据会在loader函数中进行操作
  1. import httpGeneral from '~/http/general';
  2. export async function loader(args) {
  3. // Start fetching non-critical data without blocking time to first byte
  4. const deferredData = loadDeferredData(args);
  5. // Await the critical data required to render initial state of the page
  6. const criticalData = await loadCriticalData(args);
  7. // const data = await HttpQuote.getQuote({key: 'value'});
  8. const data = await httpGeneral.getGeneral({key: 'value'});
  9. // 确保 criticalData.product.metafields 是一个有效的数组
  10. const metafields = Array.isArray(criticalData.product.metafields)
  11. ? criticalData.product.metafields
  12. : [];
  13. // 查找 related_products 字段,确保每个项都是对象并且具有 key 属性
  14. const relatedProducts =
  15. metafields.find((item) => item && item.key === 'related_products') || null;
  16. const relatedHandle = relatedProducts?.reference?.handle;
  17. const recommendedData = await loadRecommendedData({
  18. context: args.context,
  19. relatedHandle,
  20. });
  21. const {locale} = args.params;
  22. return defer({
  23. ...deferredData,
  24. ...criticalData,
  25. ...recommendedData,
  26. locale,
  27. data,
  28. // pageData,
  29. });
  30. }
  • 然后在组件中去使用useLoaderData函数去获取
  1. import {useLoaderData} from '@remix-run/react';
  2. export default function Blogs() {
  3. /** @type {LoaderReturnData} */
  4. const {blogs} = useLoaderData();
  5. return (
  6. <div className="blogs">
  7. <h1>Blogs</h1>
  8. <div className="blogs-grid">
  9. <Pagination connection={blogs}>
  10. {({nodes, isLoading, PreviousLink, NextLink}) => {
  11. return (
  12. <>
  13. <PreviousLink>
  14. {isLoading ? 'Loading...' : <span> Load previous</span>}
  15. </PreviousLink>
  16. {nodes.map((blog) => {
  17. return (
  18. <Link
  19. className="blog"
  20. key={blog.handle}
  21. prefetch="intent"
  22. to={`/blogs/${blog.handle}`}
  23. >
  24. <h2>{blog.title}</h2>
  25. </Link>
  26. );
  27. })}
  28. <NextLink>
  29. {isLoading ? 'Loading...' : <span>Load more ↓</span>}
  30. </NextLink>
  31. </>
  32. );
  33. }}
  34. </Pagination>
  35. </div>
  36. </div>
  37. );
  38. }

常用的Components、Hooks、Utilites

Components - Hydrogen

  • 购物车组件,一般使用在结账逻辑、商品添加、购物车页面逻辑,不必再自己造轮子封装逻辑。

参考:https://shopify.dev/docs/api/hydrogen/2023-07/components/cartform

  1. import { CartForm } from '@shopify/hydrogen';
  • 媒体组件(ExternalVideo、Image、MediaFile、ModelViewer、Video)

参考:https://shopify.dev/docs/api/hydrogen-react/2024-07/components/media/externalvideo

  1. import {Image,ExternalVideo,MediaFile,ModelViewer,Video} from '@shopify/hydrogen';

Components - Remix

  1. import { Await, Form, Link, useRouteLoaderData } from "@remix-run/react";

Hooks - Hydrogen

  1. import { useCart, useShop } from "@remix-run/react";

Hooks - Remix

  1. import { useNavigate, useLocation, useNavigate, useNavigation, useParam, useRouteLoaderData } from "@remix-run/react";

Utilites

  1. 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/

开发注意事项

  1. 在安装引用了第三方库时,有时候会报错,require is not define等等类似的错误,这时候应该在vite.config.js文件中写入相关配置
  1. ssr: {
  2. optimizeDeps: {
  3. /**
  4. * Include dependencies here if they throw CJS<>ESM errors.
  5. * For example, for the following error:
  6. *
  7. * > ReferenceError: module is not defined
  8. * > at /Users/.../node_modules/example-dep/index.js:1:1
  9. *
  10. * Include 'example-dep' in the array below.
  11. * @see https://vitejs.dev/config/dep-optimization-options
  12. */
  13. include: ['这里填写你的引用依赖值'],
  14. },
  15. },
  1. 请求读取Shopify的数据时,会有一个缓存策略,https://shopify.dev/docs/storefronts/headless/hydrogen/caching

React + Remix 前端代码开发规范工程化 - 图9

  1. 添加内容安全策略( CSP) https://shopify.dev/docs/storefronts/headless/hydrogen/content-security-policy
  1. const {nonce, header, NonceProvider} = createContentSecurityPolicy({
  2. styleSrc: [
  3. "'self'",
  4. 'https://cdn.shopify.com',
  5. 'https://some-custom-css.cdn',
  6. ],
  7. });
  1. 使用vite-plugin-svgr插件
  • 安装依赖包npm i -D vite-plugin-svgr
  1. import svgr from 'vite-plugin-svgr';
  2. plugins: [
  3. hydrogen(),
  4. oxygen(),
  5. remix({
  6. presets: [hydrogen.preset()],
  7. future: {
  8. v3_fetcherPersist: true,
  9. v3_relativeSplatPath: true,
  10. v3_throwAbortReason: true,
  11. },
  12. }),
  13. tsconfigPaths(),
  14. svgr(),
  15. ],
  • 使用
  1. import HomepageIconSvg2 from '~/assets/svg/homepage-icon-2.svg?react';
  2. const SolutionLearnMoreCard = ({
  3. }) => {
  4. return (
  5. <div className={styles.solution_learn_more_card}>
  6. {/* 背景图片 */}
  7. <img src={imageSrc} alt={imageAlt} />
  8. {/* 文案 */}
  9. <div className={styles.text_container}>
  10. <AnimationText>
  11. <div className={styles.text}>
  12. <div className={styles.title}>{mainTitle}</div>
  13. <div className={styles.desc}>{description}</div>
  14. <Link
  15. className={styles.more}
  16. to={handle ? `/${handle}${link}` : link}
  17. >
  18. <span className={styles.more_text}>{button}</span>
  19. <RightArrowSvg />
  20. </Link>
  21. </div>
  22. </AnimationText>
  23. </div>
  24. </div>
  25. );
  26. };

参考链接

其他

使用到Hydrogen的项目