Shopify Remix App从注册到上线

3,566 阅读13分钟

前言

本文面向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信息(个人开发的话随便填就行)

image.png

创建开发商店

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

image.png

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

image.png

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

image.png

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

image.png

image.png

开发环境准备

本节信息源自官方文档: 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个新的项目。

  1. 在存放Shopify项目的目录下打开管理员终端,注意要管理员,否则会出现某些依赖无权限和无法创建 Cloudflare隧道的问题。

  2. 执行 shopify app init初始化命令。

  3. 按照提示和下图输入内容,注意名称内不能含有shopify。 image.png

  4. 等待安装依赖,这个过程耗时大概5-10分钟。(这个时候你可以摸摸鱼去学习下 Remix 的基本概念和用法。) image.png

  5. 安装完成后我们根据提示cd 进项目目录,执行pnpm run dev 启动本地开发服务。这个命令会干以下几件事:

    1. 引导绑定你的Shopify partner账号和对应Partner organization
    2. 在组织中创建或者指定已有应用,并将本地代码连接到该应用
    3. 创建 Prisma SQLite 数据库用于存放session等数据
    4. 自动使用 Cloudflare 创建一个内网穿透,让应用能够使用 HTTPS URL 访问,这个URL配置在这个页面。(嵌入式应用核心原理还是通过Iframe加载进Shopify商店后台,通过Shopify App Bridge等进行数据互通) image.png 如果出现报错,可尝试重新pnpm i一下(这个过程视网络环境也会需要一些时间。这个时候你可以摸摸鱼
  6. 启动成功后如下图所示,我们可以通过按P自动跳转到商店后台进行安装。 image.png

    image.png

    image.png

  7. 至此,我们就完成了App的创建和开发环境的启动,可以愉快地开发了。

简单食用Remix

本节假设你不大会Remix,第一次上手,否则请跳过本章。

Remix是什么

Remix 是由 React Router 的创建者开发的全栈Web框架,专注于提升应用性能和开发体验,强调服务器渲染、快速数据加载和响应式设计。如果你没用过Next,Remix入门全栈也挺好。

主要功能

这里列几个简单特性:

路由: 使用文件系统来定义路由。每个文件对应一个 URL,同时支持嵌套和动态路由。

加载数据: 通过 Loader 方法在服务器端加载和准备数据,避免客户端多余的 API 请求。 数据加载函数可以在组件渲染之前运行,确保页面在初始渲染时已经准备好所有数据。

表单提交: 提供简便的 Form 处理机制,通过 Action 方法,可以快速处理表单提交和数据操作。

示例

打开刚刚创建的Shopify App项目,关注到app/routes 目录下,创建一个app.dice.tsx文件

image.png

上节提过,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路由补充进去

image.png

启动开发服务,进入Shopify 后台预览界面,可以看到左侧新增了摇骰子菜单,点击进去如下图。

image.png

观察本地服务端控制台和浏览器控制台:

image.png

image.png

可以发现加载路由时候会先执行async的Loader函数,等待完成后页面组件才开始渲染。更多信息可以查看代码注释,可以自己玩玩,建议参照Remix官网的上手文档跟着走一遍,这里是把Remix的核心特性做个简单示例。

开发规范引入

每个项目都必须要规范,即便项目的整个开发生命周期都是你一个人,也必须要。规范包括代码逻辑正确且完整且风格统一(Eslint)、代码书写风格统一(Prettier)、样式代码风格统一(Stylelint)、Git提交风格统一(Commitlint),借助Husky做统一检查。

具体实施可查看我的另一篇基于Vue3做规范的文章 《从0到1实践,企业级前端开发底层规范搭建》,大同小异,这里提示几点:

  • 如果是按我的流程走到这的,可以先删除目录下的.git文件夹,再自行git init到你的仓库
  • 安装依赖的时候,pnpm会提示当前处于工作区根目录(workspace),问我们是否确定要安装。解决办法是在根目录的.npmrc文件中加一行ignore-workspace-root-check=true即可。 image.png
  • 本节完成后可以push下代码。

请求封装

请求封装是每个项目都要做的事情,不然一方面会产生很多冗余代码,另外很可能会对某些异常场景处理不到。按照我之前封装的思路:

image.png

先看一下我封装后的调接口的方式:

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

启动服务看看

image.png

image.png

没问题,浏览器跟服务器端调用接口和接收响应的方式都是一致的,这下可以愉快的跟后台 (也可能是自己) 对接了!

Shopify用户隐私数据接口开发

这个配置位于Shopify App配置页,如果我们的App是public的,即上架应用商店,允许所有商店安装的,如果不照做的话就不能通过审核。

image.png

研究后发现,最简单的,只要实现三个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

image.png

我们现在创建一个App-Prod看看

image.png

image.png

可以发现已经自动帮我们创建了一个新的App和对应的toml文件,并默认在这个App上运行,我们run dev看看

image.png

这个时候会让我们重新选择开发商店,并问我们要不哟自动更新url,这里选yes就好,毕竟还没上线

然后按p去预览,重新走一下安装流程

image.png

可以发现之前项目在新的App好好运行着,太妙了!

然后我们执行pnpm run config:use,切回开发App

image.png

这里我们选择下边那个,因为一开始创建项目的时候默认是没有后缀的。

这样就完成了新的App的创建和切换。

注意:我们永远不要在生产App上进行开发,只在权限需要变更需要deploy的时候才切过去执行下命令然后马上切回来

常规上线流程

常规的,我们需要找一台服务器,把代码通过各种方式拷贝进去,执行打包和启动命令即可

pnpm run build
pnpm run start

然后简单配置下Nginx,域名映射好,然后把域名填进生产App的配置内即可完善线上部署

image.png

假如你的App是做出来给所有商店使用的,那就在Distribution页选择Public,否则选择Custom

注意:Custom只能给某一个商店或组织使用

image.png

选择好后,把Listing优化下,头像Icon上传下就可以提交审核了!

写作规划

当然这个上线流程无疑是低效的,我们注意到Shopify cli给我们创建的模板中根目录有一个Dockerfile文件,意味着我们完全可以通过Jenkins + Docker去实现线上App的持续部署。

但因篇幅原因,我们先到此为止,后边我会分享不限以下内容:

  • Jenkins + Docker持续部署
  • 权限业务示例 - 获取产品列表
  • 扩展插件安装&配置示例 - Pixel
  • 扩展插件安装&配置示例 - 向商店前台插入JS
  • 一些提高开发效率的思考

项目源码

gitee.com/somecat/sho…

写作、开源不易,求求大家转评赞呀!有纰漏也欢迎指正!