前言
本文面向Shopify App开发者,非主题开发!有一定的React开发经验即可安全快速食用。
基于Remix开发了近半年的Shopify App了,因为东西比较新,用的人相对少,网上能找的资料也不是很多,我的几个员工(GPT、通义灵码、豆包)偶尔还答非所问,终于在最近稳定后,决定写篇文章从头捋一遍Shopify Remix App开发的流程。
读完本文你将学到
- Shopify Partner账号注册与开发环境准备
- 创建Shopify App Remix模板项目
- 简单食用Remix - 页面渲染逻辑
- 简单食用Remix - 新增一个页面
- 项目规范引入 - Eslint + Prettier + Stylelint + Commitlint
- 请求封装和接口管理
- Shopify用户隐私数据接口开发(上线必须)
- 开发环境与线上环境的切换
- 常规上线流程
不想读或者读完的同学可直接查看项目源码:gitee.com/somecat/sho…
准备工作
Shopify partner账号
在开始之前你需要一个开发者账号 点此注册
注册时候这里选择构建App,选第四个定制App也行,都一样,然后填写完整你的partner信息(个人开发的话随便填就行)

创建开发商店
注册完成后,Shopify会提示已经自动创建了一个quickstart开发商店

你也可以点击Add Store创建一个。这里我们选择在稳定版本上、带上测试数据创建开发商店。

确认后,在这个页面稍等一下,Shopify后台会帮我们自动完善测试数据。

准备好后,即可点击左侧预览按钮查看你的开发商店前台。


开发环境准备
本节信息源自官方文档: shopify.dev/docs/api/sh…
账号跟开发商店准备好后,即可着手准备开发。
环境要求
- Node.js: 18.20+, 20.10 or higher
- Git: 2.28.0 or higher
安装 Shopify-cli
本文使用pnpm作为依赖管理工具,也推荐您使用pnpm
pnpm install -g @shopify/cli@latest
项目创建和启动
本节信息源自官方文档:shopify.dev/docs/apps/b…
使用 Shopify CLI 创建1个新的项目。
-
在存放Shopify项目的目录下打开管理员终端,注意要管理员,否则会出现某些依赖无权限和无法创建 Cloudflare隧道的问题。
-
执行
shopify app init初始化命令。 -
按照提示和下图输入内容,注意名称内不能含有shopify。

-
等待安装依赖,这个过程耗时大概5-10分钟。(这个时候你可以
摸摸鱼去学习下 Remix 的基本概念和用法。)
-
安装完成后我们根据提示cd 进项目目录,执行
pnpm run dev启动本地开发服务。这个命令会干以下几件事:- 引导绑定你的Shopify partner账号和对应Partner organization
- 在组织中创建或者指定已有应用,并将本地代码连接到该应用
- 创建 Prisma SQLite 数据库用于存放session等数据
- 自动使用 Cloudflare 创建一个内网穿透,让应用能够使用 HTTPS URL 访问,这个URL配置在这个页面。(嵌入式应用核心原理还是通过Iframe加载进Shopify商店后台,通过Shopify App Bridge等进行数据互通)
如果出现报错,可尝试重新pnpm i一下(这个过程视网络环境也会需要一些时间。这个时候你可以摸摸鱼)
-
启动成功后如下图所示,我们可以通过按
P自动跳转到商店后台进行安装。


-
至此,我们就完成了App的创建和开发环境的启动,可以愉快地开发了。
简单食用Remix
本节假设你不大会Remix,第一次上手,否则请跳过本章。
Remix是什么
Remix 是由 React Router 的创建者开发的全栈Web框架,专注于提升应用性能和开发体验,强调服务器渲染、快速数据加载和响应式设计。如果你没用过Next,Remix入门全栈也挺好。
主要功能
这里列几个简单特性:
路由: 使用文件系统来定义路由。每个文件对应一个 URL,同时支持嵌套和动态路由。
加载数据: 通过 Loader 方法在服务器端加载和准备数据,避免客户端多余的 API 请求。 数据加载函数可以在组件渲染之前运行,确保页面在初始渲染时已经准备好所有数据。
表单提交: 提供简便的 Form 处理机制,通过 Action 方法,可以快速处理表单提交和数据操作。
示例
打开刚刚创建的Shopify App项目,关注到app/routes 目录下,创建一个app.dice.tsx文件
上节提过,Remix支持嵌套路由,在文件名中以.分隔父子路由,创建后我们将以下示例代码复制进去。
import {
Box,
Card,
Page,
Text,
} from "@shopify/polaris";
import type { LoaderFunction, ActionFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData, useFetcher } from "@remix-run/react";
import { useEffect } from "react";
// 生成 1 到 6 之间的随机数
async function rollDice() {
return new Promise<number>(resolve => {
setTimeout(() => {
resolve(Math.floor(Math.random() * 6) + 1);
}, 3000)
})
}
// loader 函数,服务端执行,一般用于获取初始数据并传递给对应的组件。
// 这里是调用rollDice方法生成随机数,传递给Dice组件。默认情况下,Dice组件会等待 loader 函数执行完毕后再渲染。
export const loader: LoaderFunction = async () => {
console.log('加载Loader', new Date().toLocaleString())
const initialRoll = await rollDice();// 生成初始随机数
console.log('Loader结果', initialRoll, new Date().toLocaleString()); // 输出首次摇到的数字
return json({ roll: initialRoll });
};
// shouldRevalidate函数:用于控制在调用 action 时是否重新执行 loader。
// 这里返回 false,表示调用 action 时不需要重新执行 loader 函数。Remix
export function shouldRevalidate() {
return false;
}
// action 函数,服务端执行,一般用于处理非Get请求(这里是重新摇骰子的请求)。
// 这个例子中,下方组件中定义了一个Form表单,表单没有指定action即默认执行当前的action函数
export const action: ActionFunction = async () => {
const newRoll = await rollDice();// 生成新的随机数
console.log('重摇', newRoll); // 输出重摇到的数字
return json({ roll: newRoll }); // 返回包含新摇到数字的 JSON 数据
};
// 用于展示摇骰子页面的组件,客户端执行
export default function Dice() {
// 在组件挂载时,useLoaderData 钩子会从服务器获取由 loader 函数提供的数据(初始的骰子大小)。组件可以使用获取到的数据进行渲染。
const { roll } = useLoaderData<{ roll: number }>();
// useFetcher 用于处理表单提交等操作。在这个例子中,当用户点击 "重摇" 按钮提交表单时,会触发 fetcher 的提交状态变化。
const fetcher = useFetcher<{ roll: number }>()
// 通过判断 fetcher.state 的值,可以确定当前是否正在提交(isRolling),以及获取最新的数据(fetcher.data?.roll)用于组件的渲染。
const isRolling = fetcher.state === "submitting"; // 判断是否正在摇
const currentRoll = fetcher.data?.roll ?? roll; // 获取当前摇到的数字
useEffect(() => {
console.log('组件渲染', currentRoll, new Date().toLocaleString());
}, [currentRoll]);
return (
<Page>
<Box>
<Card>
<Text as="h1" variant="headingMd">摇骰子</Text>
<p>当前点数: {isRolling ? "摇动中..." : currentRoll}</p>
<fetcher.Form method="post">
<button type="submit" disabled={isRolling}>
{isRolling ? "摇动中..." : "重摇"}
</button>
</fetcher.Form>
</Card>
</Box>
</Page>
);
}
然后在app.tsx文件中将dice路由补充进去
启动开发服务,进入Shopify 后台预览界面,可以看到左侧新增了摇骰子菜单,点击进去如下图。
观察本地服务端控制台和浏览器控制台:
可以发现加载路由时候会先执行async的Loader函数,等待完成后页面组件才开始渲染。更多信息可以查看代码注释,可以自己玩玩,建议参照Remix官网的上手文档跟着走一遍,这里是把Remix的核心特性做个简单示例。
开发规范引入
每个项目都必须要规范,即便项目的整个开发生命周期都是你一个人,也必须要。规范包括代码逻辑正确且完整且风格统一(Eslint)、代码书写风格统一(Prettier)、样式代码风格统一(Stylelint)、Git提交风格统一(Commitlint),借助Husky做统一检查。
具体实施可查看我的另一篇基于Vue3做规范的文章 《从0到1实践,企业级前端开发底层规范搭建》,大同小异,这里提示几点:
- 如果是按我的流程走到这的,可以先删除目录下的.git文件夹,再自行
git init到你的仓库 - 安装依赖的时候,pnpm会提示当前处于工作区根目录(workspace),问我们是否确定要安装。解决办法是在根目录的
.npmrc文件中加一行ignore-workspace-root-check=true即可。 - 本节完成后可以push下代码。
请求封装
请求封装是每个项目都要做的事情,不然一方面会产生很多冗余代码,另外很可能会对某些异常场景处理不到。按照我之前封装的思路:
先看一下我封装后的调接口的方式:
const [error, data] = await $getInfo({ query: '2' })
if (!error) {
console.log('组件中调用远程接口成功', data)
}
是不是很优雅,符合直觉地处理响应流,又能够对错误按需定制化响应。这里还是基于Axios去封装请求,原因是Axios可以兼容Nodejs和浏览器端几乎无差别使用,只是Remix中封装Axios会比非全栈项目多一点点麻烦。下面我们开始:
安装Axios
pnpm add axios
继续前,我们创建好几个目录,并配置别名方便import
/app/api: 用于存放接口/app/config: 用于存放项目的部分配置项和拦截器/app/plugins: 用于存放项目插件示例,如axios、封装好的api等等/app/types: 用于各种类型定义
tsconfig.json写入别名:
...
"paths": {
"@/*": ["./app/*"],
"Types/*": ["./app/types/*"],
"Apis/*": ["./app/api/*"],
"Plugins/*": ["./app/plugins/*"],
"Config/*": ["./app/config/*"]
},
...
vite.config.ts写入别名:
...
resolve: {
// 设置别名
alias: {
'@': path.resolve(__dirname, 'app'),
Apis: path.resolve(__dirname, 'app/api'),
Plugins: path.resolve(__dirname, 'app/plugins'),
Types: path.resolve(__dirname, 'app/types'),
Config: path.resolve(__dirname, 'app/config'),
},
},
...
创建/app/plugins/axios.ts文件用于创建axios实例
import axios from 'axios'
import { AXIOS_DEFAULT_CONFIG } from 'Config/index'
import { requestSuccessFunc, requestFailFunc, responseSuccessFunc, responseFailFunc } from 'Config/interceptors/axios'
// 创建axios实例
const axiosInstance = axios.create(AXIOS_DEFAULT_CONFIG)
// 注入请求拦截
axiosInstance.interceptors.request.use(requestSuccessFunc, requestFailFunc)
// 注入失败拦截
axiosInstance.interceptors.response.use(responseSuccessFunc, responseFailFunc)
export default axiosInstance
创建/app/config/index.ts文件
// axios 默认配置
export const AXIOS_DEFAULT_CONFIG = {
timeout: 50000,
maxContentLength: 2000,
}
// API 默认配置
export const API_DEFAULT_CONFIG = {
mockBaseUrl: '/API', // 本地开发mock接口地址前缀,会被vite.config.ts中设置的proxy替换掉
}
创建/app/config/interceptors/axios.ts文件存放请求响应拦截器
import type { InternalAxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
/**
* 请求成功拦截器
* @param {InternalAxiosRequestConfig} requestObj - 请求配置对象
* @returns {InternalAxiosRequestConfig} - 处理后的请求配置对象
*/
export function requestSuccessFunc(requestObj: InternalAxiosRequestConfig) {
// 添加通用的请求头
requestObj.headers['Authorization'] = `Bearer XXXXXXXXXXXXXXX` // 示例,添加授权参数
// 可以在此添加一些请求前的处理逻辑,如加载动画的开启
return requestObj
}
/**
* 请求失败拦截器
* @param {AxiosError} requestError - 请求错误对象
* @returns {Promise<never>} - 拒绝的Promise,包含错误对象
*/
export function requestFailFunc(requestError: AxiosError) {
// 可以在此添加请求失败的处理逻辑,如提示用户网络错误等
// 可以在此关闭加载动画
return Promise.reject(requestError)
}
/**
* 响应成功拦截器
* @param {AxiosResponse} responseObj - 响应对象
* @returns {AxiosResponse | Promise<never>} - 处理后的响应对象或拒绝的Promise
*/
export function responseSuccessFunc(responseObj: AxiosResponse) {
// 根据业务逻辑处理响应数据
if (responseObj.status === 200 && responseObj.data.result === 'success') {
return responseObj.data
} else {
// 可以在此处理业务上的失败,如提示用户操作失败
console.error('业务失败', responseObj.data.message)
return Promise.reject(responseObj)
}
}
/**
* 响应失败拦截器
* @param {AxiosError} responseError - 响应错误对象
* @returns {Promise<never>} - 拒绝的Promise,包含错误对象
*/
export function responseFailFunc(responseError: AxiosError) {
// 响应失败处理,如根据 responseError.response?.status 做不同的处理
console.error('响应失败', responseError)
if (responseError.response) {
switch (responseError.response.status) {
case 401:
console.error('未授权,重定向到登录页')
// 可以在此执行重定向到登录页的逻辑
break
case 403:
console.error('拒绝访问')
break
case 404:
console.error('请求地址出错')
break
case 500:
console.error('服务器内部错误')
break
default:
console.error('其他错误', responseError.response.status)
}
} else {
console.error('请求失败', responseError.message)
}
// 可以在此关闭加载动画
return Promise.reject(responseError)
}
API封装
Remix作为一个全栈框架,服务端(NodeJs环境)访问接口时,不像浏览器端访问'/api/info'时候会自动补全当前的hostname,服务端会认为这不是一个合法接口而直接报错,所以我们需要获取访问服务端的url然后补全。依这个思路我们创建/app/plugins/api.ts文件:
import axios from './axios'
import { API_DEFAULT_CONFIG } from 'Config/index'
import { CustomAxiosRequestConfig } from 'Types/api'
// 根据当前环境设置baseUrl
const baseUrl = import.meta.env.DEV ? API_DEFAULT_CONFIG.mockBaseUrl : ''
// 封装API请求
const API = (option: CustomAxiosRequestConfig): Promise<[string | null, unknown]> => {
// 如果是get请求,就将data作为params
option.method?.toLowerCase() === 'get' && (option['params'] = option.data)
if (typeof window === 'undefined') {
// 服务端环境中
if (option.isLocal) {
// process.env.SHOPIFY_APP_URL拿到的当前访问地址,在非Shopify的Remix项目中,可以在root.tsx获取url保存到全局供此处使用
option['url'] = process.env.SHOPIFY_APP_URL! + option['url']
} else {
// 服务端访问非本地接口,则使用对应接口地址或者用baseUrl反向代理
!option['url']?.includes('http') && (option['url'] = process.env.SHOPIFY_APP_URL! + baseUrl + option['url'])
}
} else {
// 浏览器环境访问非本地接口,则使用baseUrl用于反向代理,否则直接访问即可
!option.isLocal && (option['url'] = baseUrl + option['url'])
}
return new Promise((resolve) => {
axios(option)
.then((response) => {
resolve([null, response.data])
})
.catch((err) => {
resolve([err, undefined])
})
})
}
export default API
创建/app/types/api.ts文件,把API请求参数设置好
import type { AxiosRequestConfig } from 'axios'
export interface CustomAxiosRequestConfig extends AxiosRequestConfig {
isLocal?: boolean
}
OK,这样就封装好了,下面我们找些例子试验下。
我们可以通过模板快速创建一个本地的Remix常规项目,然后在那个项目中创建/app/routes/api.info/route.tsx文件,写入以下代码就能作为一个第三方接口了:
import { json } from '@remix-run/node'
import type { LoaderFunction, LoaderFunctionArgs } from '@remix-run/node'
export const loader: LoaderFunction = async ({ request }: LoaderFunctionArgs) => {
const params = new URL(request.url).searchParams
return json({
result: 'success',
data: `我是项目A,你请求了我的接口,参数是${params}`,
})
}
然后pnpm run dev,拿到地址端口后挂在一边,回到我们的Shopify项目,在vite.config.ts加入代理配置:
...
server: {
...
proxy: {
'/API': {
target: 'http://localhost:5173', // 刚刚拿到的本地项目地址
changeOrigin: true,
rewrite: (path) => path.replace(/^\/API/, ''),
},
},
},
...
创建/app/routes/api.local.info/route.tsx文件,作为本地接口
import { json } from '@remix-run/node'
import type { LoaderFunction, LoaderFunctionArgs } from '@remix-run/node'
export const loader: LoaderFunction = async ({ request }: LoaderFunctionArgs) => {
const params = new URL(request.url).searchParams
return json({
result: 'success',
data: `你请求了本地接口,参数是${params}`,
})
}
现在声明几个接口看看,创建·/app/api/example.ts
import API from 'Plugins/api'
import type { AxiosRequestConfig } from 'axios'
// 这里约定所有的接口方法名前加个“$”前缀,跟普通方法名区分开
// 【本地接口】获取本地信息
export interface GetLocalInfoDTO {
query: string
}
export interface GetLocalInfoResultDTO {
result: string
data: string
}
export async function $getLocalInfo(data?: GetLocalInfoDTO, options: AxiosRequestConfig = {}) {
return API({
isLocal: true, // 本地接口时 isLocal必须为true,否则闪退!
url: '/api/local/info',
method: 'GET',
data,
...options,
})
}
// 【其他项目接口】获取其他项目信息
export interface GetAnotherInfoDTO {
query: string
}
export interface GetAnotherInfoResultDTO {
result: string
data: string
}
export async function $getAnotherInfo(data?: GetAnotherInfoDTO, options: AxiosRequestConfig = {}) {
return API({
url: '/api/info',
method: 'GET',
data,
...options,
})
}
注意,如果声明的接口是本地项目的,需要传入
isLocal: true
创建/app/routes/app.request-example.tsx
import { Card, Layout, Page, BlockStack } from '@shopify/polaris'
import { LoaderFunction } from '@remix-run/node'
import { TitleBar } from '@shopify/app-bridge-react'
import { useEffect } from 'react'
import { $getLocalInfo, $getAnotherInfo } from 'Apis/example'
export const loader: LoaderFunction = async () => {
// loader中调用自身项目写好的接口
const init1 = async () => {
const [error, data] = await $getLocalInfo({ query: '1' })
if (!error) {
console.log('Loader中调用自身项目的接口成功', data)
}
}
// loader中调用远程接口
const init2 = async () => {
const [error, data] = await $getAnotherInfo({ query: '2' })
if (!error) {
console.log('loader中调用远程接口成功', data)
}
}
await init1()
await init2()
return null
}
export default function RequestExamplePage() {
// 组件中调用自身项目写好的接口
const init1 = async () => {
const [error, data] = await $getLocalInfo({ query: '1' })
if (!error) {
console.log('组件中调用自身项目的接口成功', data)
}
}
// 组件中调用远程接口
const init2 = async () => {
const [error, data] = await $getAnotherInfo({ query: '2' })
if (!error) {
console.log('组件中调用远程接口成功', data)
}
}
useEffect(() => {
init1()
init2()
}, [])
return (
<Page>
<TitleBar title="请求封装示例页面" />
<Layout>
<Layout.Section>
<Card>
<BlockStack gap="300">请在开发终端和浏览器终端查看打印内容</BlockStack>
</Card>
</Layout.Section>
</Layout>
</Page>
)
}
在/app/routes/app.tsx中,写入菜单
...
<NavMenu>
<Link to="/app" rel="home">
Home
</Link>
<Link to="/app/dice">(入门示例)摇骰子</Link>
<Link to="/app/request-example">请求封装示例</Link>
</NavMenu>
...
启动服务看看
没问题,浏览器跟服务器端调用接口和接收响应的方式都是一致的,这下可以愉快的跟后台 (也可能是自己) 对接了!
Shopify用户隐私数据接口开发
这个配置位于Shopify App配置页,如果我们的App是public的,即上架应用商店,允许所有商店安装的,如果不照做的话就不能通过审核。
研究后发现,最简单的,只要实现三个POST接口(用户数据访问、用户数据擦除、商店数据擦除),每个接口能对请求头中的X-Shopify-Hmac-SHA256签名与自己的SHOPIFY_API_SECRET进行HMAC计算编码后的哈希值进行验证,如果验证失败返回401,验证成功正常返回success即可。
我们快速创建3个文件:/app/routes/api.customer.info/route.tsx、/app/routes/api.customer.erasure/route.tsx、/app/routes/api.shop.erasure/route.tsx,里边都写上以下代码即可:
import type { ActionFunctionArgs } from "@remix-run/node";
import { LoaderFunction, json } from "@remix-run/node";
import crypto from "node:crypto";
export const loader = async () => {
return json({});
};
export const action: LoaderFunction = async ({ request }: ActionFunctionArgs) => {
if (request.method !== "POST") {
throw new Response("This route only supports POST requests", { status: 405 });
}
const reqClone = request.clone();
const rawPayload = await reqClone.text();
const signature = request.headers.get("X-Shopify-Hmac-SHA256");
const getHash = crypto
.createHmac("sha256", process.env.SHOPIFY_API_SECRET!)
.update(rawPayload)
.digest("base64");
if (signature !== getHash) {
throw new Response("Signature mismatch", { status: 401 });
}
return json({ message: "Request completed successfully" })
};
在上线后,提交审核前,回到配置页,把我们的线上域名+上对应的路由填入对应的表单中即没问题了。
开发环境与线上环境的切换
本节知识点源于Shopify cli官方文档:shopify.dev/docs/api/sh…
开发测试差不多后,我们就要准备线上环境了额,Shopify App跟常规项目不一样的,这是以不同App作为不同环境的,意味着我们开发后需要将代码绑定到线上App,提测时绑到测试专用App。不过还好Shopify cli提供了指令帮我们快速创建和切换。
shopify app config link: 创建或者绑定某个App,此操作会在项目根目录创建对应的.toml文件,存放App的各项配置,包括ID、权限范围、授权接口、webhooks等等。shopify app config use: 指定运行cli命令时的默认App,如我们指定dev app,则pnpm run dev时候就会在这个dev app上运行,不会影响线上App。
我们首先执行pnpm shopify app config link或者pnpm run config:link,可以创建一个新的App用于生产环境,如已经在Partner那边创建过了就可以选择No, connect it to an existing app
我们现在创建一个App-Prod看看
可以发现已经自动帮我们创建了一个新的App和对应的toml文件,并默认在这个App上运行,我们run dev看看
这个时候会让我们重新选择开发商店,并问我们要不哟自动更新url,这里选yes就好,毕竟还没上线
然后按p去预览,重新走一下安装流程
可以发现之前项目在新的App好好运行着,太妙了!
然后我们执行pnpm run config:use,切回开发App
这里我们选择下边那个,因为一开始创建项目的时候默认是没有后缀的。
这样就完成了新的App的创建和切换。
注意:我们永远不要在生产App上进行开发,只在权限需要变更需要
deploy的时候才切过去执行下命令然后马上切回来!
常规上线流程
常规的,我们需要找一台服务器,把代码通过各种方式拷贝进去,执行打包和启动命令即可
pnpm run build
pnpm run start
然后简单配置下Nginx,域名映射好,然后把域名填进生产App的配置内即可完善线上部署
假如你的App是做出来给所有商店使用的,那就在Distribution页选择Public,否则选择Custom
注意:Custom只能给某一个商店或组织使用
选择好后,把Listing优化下,头像Icon上传下就可以提交审核了!
写作规划
当然这个上线流程无疑是低效的,我们注意到Shopify cli给我们创建的模板中根目录有一个Dockerfile文件,意味着我们完全可以通过Jenkins + Docker去实现线上App的持续部署。
但因篇幅原因,我们先到此为止,后边我会分享不限以下内容:
- Jenkins + Docker持续部署
- 权限业务示例 - 获取产品列表
- 扩展插件安装&配置示例 - Pixel
- 扩展插件安装&配置示例 - 向商店前台插入JS
- 一些提高开发效率的思考
项目源码
写作、开源不易,求求大家转评赞呀!有纰漏也欢迎指正!