Skip to content

商城开发方案调研

店匠|shopLine: 13825908351 YY2022128@

测试账号1

tsf@idd.cool

idd_12345678

测试账号2

fjc@idd.cool

Aa123456!

开源项目设计

  • 目的:操作简单,快速建站,高性能,高度适配自定义业务开发场景(支持定制商品),作为独立站开发的脚手架

需要思考的几个点,模块组件下面用于收集数据的选项组件称为dataItem

  1. 每个模块中的dataItem要有自己自定义的校验方式要是函数的方式会更加的灵活

    • 考虑种情况,有可能一个dataItem的校验依赖另外一个dataItem的情况
  2. 由于业务场景的需求,每个模块的数据一定会有相互引用的情况,所以提前思考,如果某一个模块数据被清除,或者变动怎样快速有效-简易-清晰的同步到全部的关联区域组件

    • 升级版:区域组件间的数据,最好可以有相互调用传输数据的业务场景(待定…)
  3. 想办法统一c/b端模版(这个是很大的一步)

    思考后面要有自定义模版,自定义区域…

    例:添加一个模块|区域,要带出这个组件所有的配置来渲染dataItem

    区域可以选中

    块用于收集数据

    参考Shopify 目录结构

  4. 封装适配器,最后保存的数据一定是格式化简单的数据,要提前想好两种数据格式的相互转换

  5. 封装一组可以在整个页面数据中可以随意获取修改数据的工具集合(这个设计好了,就解决了一大半了)

    • 数据的级别 页面 -》 区域组件-> 模块组件-〉数据组件 dataItem
    • 自定义页面,自定义区域组件,自定义模块,自定义数据组件
  6. 响应式布局

  7. 插件系统(p2)

  8. 多店铺(p2)

  9. 商品详情

  10. 支付

  11. 购物车(各种营销活动)

  12. 国际化翻译,定制喵和shopify 设计的都很好这个到时候考虑下

  13. 看看可不可以 mo r p

  14. 可以相互赠送礼品—发送链接-输入密码—>,链接发送过去输入密码就可以直接选

  15. 设计生命周期,模块初始化,模块更新,所有模块更新,有助于联动其他模块更新的时候,方便统一更新

  16. 设计可以收集关键数据的钩子,比如:商品详情,商品列表,商品分类,商品品牌,商品属性,商品规格,商品评论,商品评价,商品收藏,商品浏览记录,商品购买记录,商品分享记录,商品收藏记录,商品浏览记录,商品购买记录,商品分享记录,商品收藏记录,商品浏览记录,商品购买记录,商品分享记录,商品收藏记录,商品浏览记录,商品购买记录,商品分享记录,商品收藏记录,商品浏览记录,商品购买记录,商品分享记录,商品收藏记录,商品浏览记录,商品购买记录,商品分享记录,商品收藏记录,商品浏览记录,商品购买记录,商品分享记录,商品收藏

你是一位全栈开发工程师,同时精通产品规划和UI设计。 我现在想要开发一个海外商城的后台包含订单(物流等),商品分类,商品,评论,文章,插件,店铺装修(参考shopify)的基础后台管理适配c端,需要输出一套完整的web原型图,请按照下面的要求执行: - 模拟真实用户装修店铺和发布产品的真实场景和需求 - 结合用户需求,以产品经理的视角去规划商城后台功能、页面和交互 - 结合产品规划,以设计师的视角去输出完整的高保真UI/UX - 以上全部页面都在同一个html文件中平铺展示 - 页面引入tailwindcss来完成样式编写,图片使用unsplash,小图标使用fontawesome,一开发功能师的角度设计数据库表

  • 目的:操作简单,快速建站,高性能,高度适配自定义业务开发场景(支持定制商品),作为独立站开发的脚手架

需要思考的几个点,模块组件下面用于收集数据的选项组件称为dataItem

  1. 每个模块中的dataItem要有自己自定义的校验方式要是函数的方式会更加的灵活

    • 考虑种情况,有可能一个dataItem的校验依赖另外一个dataItem的情况
  2. 由于业务场景的需求,每个模块的数据一定会有相互引用的情况,所以提前思考,如果某一个模块数据被清除,或者变动怎样快速有效-简易-清晰的同步到全部的关联区域组件

    • 升级版:区域组件间的数据,最好可以有相互调用传输数据的业务场景(待定…)
  3. 想办法统一c/b端模版(这个是很大的一步)

    思考后面要有自定义模版,自定义区域…

    例:添加一个模块|区域,要带出这个组件所有的配置来渲染dataItem

    区域可以选中

    块用于收集数据

    参考Shopify 目录结构

  4. 封装适配器,最后保存的数据一定是格式化简单的数据,要提前想好两种数据格式的相互转换

  5. 封装一组可以在整个页面数据中可以随意获取修改数据的工具集合(这个设计好了,就解决了一大半了)

    • 数据的级别 页面 -》 区域组件-> 模块组件-〉数据组件 dataItem
    • 自定义页面,自定义区域组件,自定义模块,自定义数据组件
  6. 响应式布局

  7. 插件系统(p2)

  8. 多店铺(p2)

  9. 商品详情

  10. 支付

  11. 购物车(各种营销活动)

  12. 国际化翻译,定制喵和shopify 设计的都很好这个到时候考虑下

1、多租户 物理隔离的独立主题项目 c端商城多站点架构

Section titled “1、多租户 物理隔离的独立主题项目 c端商城多站点架构”
  • astro+monorepo+Turborepo( Turborepo 的“指纹缓存”机制 (核心方案))

my-ecommerce-platform/ ├── apps/ # 【主题应用层】 │ └── storefront-gateway/ # 【核心网关】中转站 │ ├── src/middleware.ts # 识别域名,动态重定向到对应主题 │ └── astro.config.mjs # SSR 模式配置 │ ├── packages/ # 【共享内核层】

│ ├── theme-minimal/ # 主题 A:极简风格 (Astro 项目) │ │ ├── src/components/ # 该主题特有的 UI │ │ └── src/pages/p/[id].astro # 极简版详情页模板 │ ├── theme-modern/ # 主题 B:现代风格 (Astro 项目) │ │ ├── src/components/ # 该主题特有的 UI │ │ └── src/pages/p/[id].astro # 现代版详情页模板

│ ├── api/ # 核心业务逻辑 (10万 SKU 查询) │ │ ├── src/mysql.ts # MySQL 数据库连接 │ │ └── src/products.ts # getProduct(id, tenantId) │ ├── core/ # 共享类型与工具 │ │ └── tenant.ts # getTenantConfig(domain) │ └── store/ # 状态管理 │ └── cart.ts # Nano Stores 跨主题购物车 │ ├── pnpm-workspace.yaml # pnpm 工作区定义 ├── turbo.json # Turborepo 缓存与任务配置 └── package.json # 根目录全局配置

storefront-gateway/src/middleware.ts
import { getTenantConfig } from "@repo/core/tenant";
export const onRequest = async (context, next) => {
const domain = new URL(context.request.url).hostname;
const tenant = await getTenantConfig(domain); // 从 MySQL 查出租户
if (!tenant) return new Response("Not Found", { status: 404 });
// 关键:将租户选定的主题(如 'theme-minimal')存入 locals
context.locals.theme = tenant.theme_name;
context.locals.tenantId = tenant.id;
return next();
};
apps/storefront-gateway/src/pages/p/[id].astro
---
import MinimalDetail from "../../theme-minimal/src/pages/p/[id].astro";
import ModernDetail from "../../theme-modern/src/pages/p/[id].astro";
const { theme } = Astro.locals;
const { id } = Astro.params;
// 根据租户选的主题,动态决定渲染哪个组件(模板)
const SelectedTheme = theme === 'minimal' ? MinimalDetail : ModernDetail;
---
/*由于所有主题最终都在“主项目”里运行,为了防止 theme-minimal 的样式污染 theme-modern,建议:
在每个主题的根容器上加一个独有的 ID 或 Class(如 <div class="theme-minimal">)。
或者在主题中使用 Scoped CSS(Astro 默认支持)。*/
<SelectedTheme id={id} />

三、升级方案开发用户主题开发

Section titled “三、升级方案开发用户主题开发”

必须跳出 “构建时(Build time)” 的思维,转向 “运行时(Runtime)” 动态渲染。

apps/storefront-gateway/src/pages/p/[id].astro
---
import { Liquid } from 'liquidjs';
import { getProductById } from '@repo/api';
const { id } = Astro.params;
const { tenant } = Astro.locals; // 中间件拿到的商户信息
// 1. 获取 10万 SKU 数据
const product = await getProductById(id, tenant.id);
// 2. 从数据库或缓存中读取该商户上传的原始 Liquid 字符串
const rawTemplate = tenant.custom_product_template;
// 3. 运行时渲染
const engine = new Liquid();
const html = await engine.parseAndRender(rawTemplate, { product, tenant });
---
<!-- 直接输出商户自定义生成的 HTML -->
<Fragment set:html={html} />

核心原理:模块联邦 (Module Federation) 或 ESM 导入

可以直接参考 cool 团队的插件系统

由于 Astro 运行在 Node.js 环境,你可以利用 ES Modules 的特性,在运行时动态加载商户存放在 OSS(如阿里云、腾讯云)上的 JS 文件。

  • 流程
    1. 商户端:使用你提供的 开发工具包 (CLI) 开发 React/Vue 组件,执行 npm run build
    2. 产物:生成一个混淆后的 theme.jstheme.css
    3. 上传:商户将文件上传到你的静态存储。
    4. Astro 渲染

为什么像 Cool 团队或微前端方案敢这么做?

他们能规避风险,通常是因为加了以下三层防护网

  1. 严格的代码审计(非技术手段)

商户上传后,代码不立刻上线,而是进入人工或自动扫描队列,检查是否存在 processevalXMLHttpRequest 等危险关键字。

  1. 沙箱隔离运行(重度技术手段)

这是最关键的。他们不会直接 import,而是把代码丢进一个**“隔离的小黑屋”**里跑:

  • Node.js VM2 / VM 模块:创建一个完全没有 process、没有 fs、没有 network 权限的虚拟环境。
  • 代码即便执行 process.exit(),也只是撞在沙箱的墙上,伤不到主进程。
  • 结果只取 HTML

主进程只向沙箱要一个结果:“喂,把商品 ID 为 123 的 HTML 算出来给我。” 算完后立刻销毁沙箱。


isolated-vm 方案 (集成在 Astro 内)

环境是 Docker,那么实现商户自研主题上传的最佳工业实践是:Sidecar(边车)容器方案

Renderer Sandbox (Node.js + isolated-vm):专门处理商户代码

开发模板开发一定是html模板引擎

Section titled “开发模板开发一定是html模板引擎”

为什么 Liquid 是安全的,而直接传代码不安全?

简单总结,上传astro代码,他会在服务器执行,但是html模板不会,他只会生成对应的html字符串到浏览器执行

方案运行机制风险点
原生 Astro 代码服务器直接编译并运行极高。商户代码拥有服务器权限,可执行任意系统指令。
Liquid 模板服务器只负责扫描字符串并替换极低。Liquid 只是一个文本处理器,它不认识也不执行系统命令。
  1. 对 JSON 进行 HTML 实体转义(最推荐)

这是 Shopify LiquidAstro 默认的做法。将所有的双引号 " 转义成 "

  • Liquid 示例

    html

    <div data-config='{{ block.settings | json | escape }}'></div>

    请谨慎使用此类代码。

  • 生成的 HTML

    html

    <div data-config='{&quot;title&quot;:&quot;今日特惠&quot;,&quot;price&quot;:99}'></div>

    请谨慎使用此类代码。

  • JS 读取:浏览器会自动还原。

    javascript

    const config = JSON.parse(el.dataset.config); // 自动拿到正确的对象

    请谨慎使用此类代码。

  • 使用 <script type="application/json">(结构最清晰)

对于 10 万 SKU 这种可能包含复杂描述、特殊符号的数据,直接塞在属性里确实比较乱。Shopify 经常用这种“隐藏标签”方案:

  • HTML 结构

    html

    <section id="section-{{ section.id }}">
    <!-- 渲染配置到脚本标签中,不显示在页面 -->
    <script type="application/json" data-settings>
    {{ section.settings | json }}
    </script>
    </section>

    请谨慎使用此类代码。

  • JS 读取

    javascript

    const container = document.getElementById('section-xxx');
    const settings = JSON.parse(container.querySelector('[data-settings]').textContent);

    请谨慎使用此类代码。

  • 优点:不需要担心引号冲突,代码可读性极高。

packages/theme-engine/
├── src/
│ ├── sections/ # 所有可用的 Section 库
│ │ ├── featured-product/
│ │ │ ├── index.liquid # 包含 Scoped CSS 的模板
│ │ │ └── index.ts # 独立 JS Class
│ │ └── hero-banner/
│ └── main.ts # 负责 JS 初始化的入口
├── dist/ # 打包后的产物
│ ├── main.js # 只有几 KB 的“打火机”脚本
│ └── chunks/ # 自动分包的 Section 逻辑 (如 fp.hash.js)
  1. render 解析page/home页面
文件路径:/section/product/这里面会有index.html [拼到这个标签的所在位置],index.js[下面js隔离方案导出的函数,],index.json[c端保存的死配置数据]
<SectionProduct></SectionProduct>
<BlockProduct></BlockProduct>
  • 打包的时候使用抽象进行编译重新生成一个完整的html,放到缓存中最好启动的时候就开始缓存,后面抽象出一个方法进行缓存
  • 预览的时候最好每个section都有一个render,每次修改配置或者代码的时候重新render,更新缓存不用刷新页面全部渲染
  1. 根据json拼装完之后在进行水和js代码
//就是这种
import { ProductGallery } from './modules/ProductGallery.js';
import { SKUSelector } from './modules/SKUSelector.js';
import { CartDrawer } from './modules/CartDrawer.js';
  1. 命名空间隔离 + css变量或者tailwindcss

    /* 这种内联方式配合 ID 选器,能精准锁定作用域 */
    #Section-{{ section.id }} .title { color: {{ section.settings.color }}; }
    #Section-{{ section.id }} .btn { border-radius: 4px; }
  2. Shadow DOM(进阶方案)

  3. JS 隔离:实例化与数据快照

  4. 面向对象组件化的写法

    没有事件的模块:不写 data-section-type,主脚本直接跳过,零开销。

// 1. 导入各个独立的模块类
import { ProductGallery } from './modules/ProductGallery.js';
import { SKUSelector } from './modules/SKUSelector.js';
import { CartDrawer } from './modules/CartDrawer.js';
// 2. 建立映射表 (Registry)
const SECTION_COMPONENTS = {
'product-gallery': ProductGallery,
'sku-selector': SKUSelector,
'cart-drawer': CartDrawer
};
// 3. 全局扫描函数
export function initAllSections(container = document) {
// 扫描所有带有 data-section-type 的元素
container.querySelectorAll('[data-section-type]').forEach(el => {
const type = el.dataset.sectionType;
const Component = SECTION_COMPONENTS[type];
if (Component && !el._instance) {
// 【关键】直接实例化对应的类,互不干扰
el._instance = new Component(el);
}
});
}
// ProductGallery.js
class ProductGallery {
constructor(container) {
this.container = container;
this.btn = container.querySelector('.btn');
// 【关键】将函数绑定到 this,并存为一个属性,确保引用唯一
this.handleClick = this.handleClick.bind(this);
this.init();
}
init() {
// 使用存好的引用添加监听
this.btn.addEventListener('click', this.handleClick);
}
handleClick(event) {
console.log('处理点击逻辑', this.container.id);
}
// 【核心方法】当商户删除模块或切换主题时调用
destroy() {
// 移除监听(引用必须与 add 时完全一致)
this.btn.removeEventListener('click', this.handleClick);
// 清空 DOM 引用,释放内存
this.container = null;
this.btn = null;
console.log('组件已卸载,内存已释放');
}
}
// 1. 导入各个独立的模块类
import { ProductGallery } from './modules/ProductGallery.js';
import { SKUSelector } from './modules/SKUSelector.js';
import { CartDrawer } from './modules/CartDrawer.js';
// 2. 建立映射表 (Registry)
const SECTION_COMPONENTS = {
'product-gallery': ProductGallery,
'sku-selector': SKUSelector,
'cart-drawer': CartDrawer
};
// 3. 全局扫描函数
export function initAllSections(container = document) {
// 扫描所有带有 data-section-type 的元素
container.querySelectorAll('[data-section-type]').forEach(el => {
const type = el.dataset.sectionType;
const Component = SECTION_COMPONENTS[type];
if (Component && !el._instance) {
// 【关键】直接实例化对应的类,互不干扰
el._instance = new Component(el);
}
});
}

走进业务

如何开始一个独立定制化项目的开发

Section titled “如何开始一个独立定制化项目的开发”
  • sunzi-modules_1x 与 下面提到的sunzi-modules框架基本相同,但是每个定制化项目基于分支来区分,包含了许多早期的定制化项目资产。但是由于各分支代表不同的项目,出于定制化项目的高度个性化原因,不同项目的公共组件、方法难以合并和维护,该问题也出现在多语种的管理上,于是诞生了sunzi-modules
  • sunzi-modules(定制化组件)采用 lerna + workspace 的开发模式,是 monorepo 项目。每一个定制化项目都相当于一个子项目,会有自己的 package.json 文件,因为依托于 yarn + workspace,所以所有的包都会安装到最外层的 node_modules 上(提升开发效率,降低磁盘消耗空间)。每个项目文件夹有自己语种文件以及项目内的公共组件文件夹,同时使用@mademine/*, @sunzi/*等内部库,该库为前端组为服务对象,提供了许多公共组件以及方法。但是随着项目的增加,出现了本地构建速度下降、运行内存占用大等问题。
  • Alone-dev 采用 vite 本地开发,webpack 打包的模式,每个项目均有自己的远端库。新的开发项目尽量使用该方式进行开发,同时为了让开发更方便,该脚手架依然等待你的优化。

为了维护已完成的项目,在已完成的项目上进行bug修复与小功能迭代,可能会接触到1x的代码。

项目目录结构

-.husky // 执行 git hooks 相关
-.mfsu-development // umi.js 升级 mfsu 之后带来的缓存文件
-.umi // umi.js 的缓存文件
-dist // 打包后存放网页的logo
-docs // 定制组件的加车参数说明文档
-node_modules // 依赖的包
-packages // 存放每一个定制化项目的地方
-public // 存放网页的logo
-scripts // 存放 打包脚本 & 代码规范校验脚本
...
// 一些配置文件
...
-.umirc.js // umi 的配置文件
-rollup.common.js
-webpack.common.js

从目录结构中可以看出我们把所有的定制化项目都存放在了 packages 里面,所以我们需要在这个目录下创建新的项目。而一个新项目的目录结构通常包括:

公司的私有库中有一个 sunzi-cli 的脚手架,可以用于创建项目模版,但可能不太好用(目前计划迭代中),也可以手动创建对应的文件夹,当创建完项目之后我们需要在 .umirc.js 入口文件上进行引用:

// 1. 打开 .umirc.js 文件
// 2. 找到 resolve.includes 入口
resolve: {
includes: [
'docs',
'packages/artistic-letter',
'packages/continent-map',
'packages/your-project'
],
},

可以对比 packages/photo-silhouette 项目代码来看下面的描述:

  • 界面统一结构:
import { Layout, Variant, CustomLayer, Wrapper } from 'sunzi-components'
// 必须使用 Layout 组件作为最外层的结构,这里存在了一个安全域名的校验
<Layout>
// 每一个定制化组件可能会存在多个选项,例如:尺寸 / 颜色 等等,这些元素我们统称为变体
// 采用 Variant 组件渲染各种不同的变体:图片变体 / 文字变体
<Variant></Variant>
// 这里存放一个按钮用来打开我们的定制化界面
<button onClick={() => setCustomLayerVisible(true)}></button>
// 开始具体的逻辑编写
{customLayerVisible && (
<CustomLayer>
// 如果是新的页面 可以使用 wrapper 组件进行包裹,通过 xxxVisible 变量进行控制
// 例如 生成预览效果 页面 previewVisible
{previewVisible && { xxx } }
</CustomLayer>
)}
</Layout>
  • 路由跳转:
// 定制化项目因为是一个 “弹窗项目”,所以没有路由的说法
// 采取的方式是基于页面的层叠,z-index
// 假设有 编辑页面 和 预览页面
import { useState } from 'react'
import styles from './style.less'
const [ editVisible, setEditVisible ] = useState<boolean>(false);
const [ previewVisible, setPreviewVisible ] = useState<boolean>(false);
{ editVisible && <Wrapper className={styles.editWrapper}>xxx</Wrapper> }
{ previewVisible && <Wrapper className={styles.previewWrapper}>xxx</Wrapper> }
<styles>
.edit-wrapper {
z-index: 10;
}
.preview-wrapper {
z-index: 11;
}
</styles>
  • 加车(加入购物车)
demo/index.tsx
// 每一个定制化项目都有一个加入购物车的环节,俗称加车
// 1. 加车就是调用外部传进来的 onConfirm 方法,然后把数据放入到这里面
import PhotoSilhouette from 'sunzi-photo-silhouette'
const Component = () => (
<PhotoSilhouette
// 变体切换时的回调,每一个项目都必须有
onVariantChange={(...data: any) => {
console.log(data);
}}
// 加车回调,每一个项目都必须有
onConfirm={(...data: any) => {
console.log(JSON.stringify(data));
alert('加车成功');
}}
/>
);
// 2. 关于“数据”的说明:指的是与后端约定成俗的数据,一般的定制化项目都会要求输出一个 Ai 图
// 而由于 Ai 图的尺寸普遍较大,在前端使用 canvas 进行绘画的话要么导致画布过大崩溃,要么导致
// 画的时间比较长,所以就把这一步放到了后端进行,除了 Ai 图之外,我们还需要传递一些诸如:用户
// 输入等信息给到其他人
{
// 和后端约定的数据结构
'_sunzi_compose': [{
'rule': 35,
'data': data.output
}],
// 其他所需的参数,根据不同的项目要求也不同
'_sunzi_sources': data._sunzi_sources,
'_sunzi_effect': data.sunzi_effect ,
'_sunzi_remark': data.sunzi_remark ,
}
// 3. 加车时的 进度条动画
import { LoadingLine } from 'sunzi-components'
<Wrapper className={styles.uploadWrapper}>
<LoadingLine />
</Wrapper>
  1. 使用账号登陆后台

  2. 选择左侧的 Products,通常产品经理会给你提供集成链接,如果没有,可以通过测试链接/正式链接,进行产品拷贝

  3. 搜索产品给你的链接项目名字

  4. 选择你的产品,在右下角加上一个标签,标签格式为 custom-sunzi-${projectName}

  5. 选择左侧 Online Store

  6. 点击右侧 Actions,选择 Edit code

  7. 在左侧的 Snippets 创建一个集成文件,命名为 sunzi-${projectName}

  8. 搜索 sunzi-photo-silhouette,可以复制这一个模板的内容放在你自己的集成文件上(根据自己实际情况进行修改)

  9. 搜索 sunzi-plugin,这个文件是存放我们所有集成项目的地方,然后按照格式把标签和你的集成文件匹配上

  10. 刷新你的测试站链接(产品提供的),如无意外则已经成功显示了

  1. astro 固定页面路由 + 渲染配置

  2. 根据配置+网关内部的分发策略 来决定每个页面渲染那些路径下组件

  3. 然后路由在主应用,区域模块在packages里,动态生成配置(渲染的时候使用) 和上面一样的