OpenBuild 官网重构

将 OpenBuild 官网项目前端工程重构为可面向社区开源的状态,即拿出去不至于丢脸,甚至要让他人看到后惊呼赞叹!😂😂😂

因而,所要满足的条件大致是:

  1. 采用「模块化」模式重新划分目录结构,将核心模块与功能迁移适配过去;
  2. 切换到 TypeScript 开发;
  3. 引入 Handie 的配置驱动开发模式。

我会最大限度复用自己过往的经验与积累,在这过程中值得记录的点都会写在《前端铲💩日记》中。😁😁😁

需求分析

了解项目代码与梳理官网功能。

当前技术栈

整个工程以 ReactNext.js 作为底层依赖,其他用到的库主要有:

类别 库/框架 备注
工具 Lodash
validator.js 字符串模式校验,极少用到,可低成本移除
Nano ID 随机 ID 生成器,极少用到,可低成本移除
数据处理 React Redux 全局状态管理,使用 useReducer 替代或完全不用状态管理
React Hook Form 表单数据处理
UI Tailwind CSS 原子 CSS 工具类
Heroicons SVG 图标
Headless UI 无样式组件
daisyUI 基于 Tailwind CSS 的类 Bootstrap CSS 组件
styled-components 利用 ES 模板字符串写样式的组件,极少用到,可低成本移除
NextUI UI 组件
ByteMD Markdown 内容编辑
SurveyJS 问卷表单
动画 aos 滚动动画
Animate.css
Framer Motion
Web3 RainbowKit
Wagmi

其中,有些很少用到或职责有冲突,考虑移除;并且,原代码跟 Next.js 耦合较紧,不便于日后迁出,亟需松绑。

工程目录结构

目前,前端工程的目录结构大体如下:

project
├── app
│ └── ...
├── components
│ └── ...
├── constants
│ └── ...
├── hooks
│ └── ...
├── lib
│ └── ...
├── services
│ └── ...
├── state
│ └── ...
├── styleds
│ └── ...
├── styles
│ └── ...
├── utils
│ └── ...
└── ...

这是妥妥的「野生」模式,可以说该模式所带来的问题在项目里几乎都具备了……😩😩😩

代码编写问题

除了上述「野生」模式所带来的常见问题外,还存在一些其他问题:

  • 页面文件中有 HTTP API 请求的拼装逻辑;
  • 界面渲染的代码中充斥着 Tailwind CSS 的类名;
  • 很多非必要的重新渲染,同时导致发起多次重复请求;
  • 列表加载与翻页逻辑大量冗余;
  • 通用的 UI 组件无法直观地区分出是 client only、server only 还是两者兼容的;
  • 哪些部分该服务端渲染,哪些该客户端渲染,并没有很好地划分与优化;
  • ……

总而言之,滥用 React Hooks 机制,几乎没有抽象,封装性极差,无论是开发者体验还是用户体验都很烂!

业务功能模块

TODO

设计

基于规范、约定、接口等「共识」编程。

调整目录结构

「模块化」模式的基础上,根据 Next.js 的限制进行些许兼容适配:

project/src
├── app
│ └── ...
├── domain
│ └── [domain-specific-module]
│ ├── views
│ │ ├── [detail-view]
│ │ │ ├── [DetailViewWidget].tsx
│ │ │ ├── ...
│ │ │ └── style.scss
│ │ ├── [form-view]
│ │ │ ├── [FormViewWidget].tsx
│ │ │ ├── ...
│ │ │ └── style.scss
│ │ └── [list-view]
│ │ ├── [ListViewWidget].tsx
│ │ ├── ...
│ │ └── style.scss
│ ├── widgets
│ │ └── [domain-specific-widget]
│ │ └── ...
│ ├── helper.ts
│ ├── index.ts
│ ├── model.ts
│ ├── repository.ts
│ └── ...
├── entry
│ ├── aspects
│ │ └── ...
│ ├── layouts
│ │ └── ...
│ └── ...
├── shared
│ ├── components
│ │ │ ├── control
│ │ │ │ └── ...
│ │ │ └── ...
│ │ └── ...
│ ├── utils
│ │ └── ...
│ ├── styles
│ │ └── ...
│ └── ...
└── ...

其中,app 除了定义页面路由之外,还取代了 entry 文件夹作为页面渲染的入口。

文件位置挪动

app 文件夹下的文件尽可能向外移:

  • 与业务逻辑强相关的弄到 domain 中;
  • 与页面渲染强相关的弄到 entry 中。

原则上 app 下只有受 Next.js 限制的兼容适配类文件。

通用性较差的全局文件也要挪到上述两个文件夹中。

文件引用规则

在文件引用层面有所限制:

  • shared 文件夹下的文件不可引用除 public 之外的任何其他外部文件夹下的文件;
  • domain 文件夹下的文件仅可引用 publicshared 和其他 domain 文件夹下的文件;
  • app 文件夹下的文件可引用 publicshareddomain 及同文件夹下的文件。

使用 @/* 引用 shared 下的文件。

拆分业务模块

TODO

重塑技术栈

通用 UI 开发以 Tailwind CSS、daisyUI、Headless UI 这三者搭配使用为主,交互上的不足之处由 NextUI 等原子组件弥补,但 styled-components 这种从「思想」层面相悖的坚决移除。

以「组件」而非「CSS 类」的形式向外透出,基于它们封装出「项目级」的通用 UI 组件放在 src/shared/components/control 文件夹中,API 尽量兼容 Petals 中定义的。

图标使用 Heroicons 和自定义的,优先用 SVG React 组件的形式,可用 Font Awesome 作为候补。

请求管理

对 HTTP API 和智能合约 ABI 进行统一控制。

HTTP API

参考 Axios 封装拥有便捷方法的构造函数以发起 HTTP API 请求,并创建两个实例去支持以 /v1/ts/v1 开头的请求地址:

function HttpClient(config) {}

// 请求便捷方法
HttpClient.prototype.get = async function(url, config) {}
HttpClient.prototype.post = async function(url, data, config) {}

// 添加请求返回拦截器
HttpClient.prototype.use = function(interceptor) {}

const legacyClient = new HttpClient({ baseUrl: '/v1' });
const httpClient = new HttpClient({ baseUrl: '/ts/v1' });

export { legacyClient };
export default httpClient;

智能合约 ABI

对智能合约 ABI 的调用使用 obj.method(params) 的形式替代 readContract(config, { address, abi, functionName, args }),这样更符合直觉且更简洁:

// 构造一个拥有 ABI 方法的对象
function constructMethods(addr, abi) {}

// 添加请求返回拦截器
function setInterceptor(interceptor) {}

export { constructMethods, setInterceptor };

请求返回结构

无论是用哪种途径发起请求,都将返回结果转换为 Organik 中定义的结构,即:

function normalizeResponse(res) {
// 根据 `res` 判断,给以下结构赋值
return {
success: true, // 是否逻辑成功
message: '', // 逻辑失败时的提示信息
code: '', // 逻辑失败时的错误编码
data: [], // 逻辑成功时的返回数据
// 逻辑成功时的附加数据,分页等信息需要挂在这个上面:
// `total` 是总条数,
// `pageNum` 是当前页,
// `pageSize` 是每页条数
extra: {},
}
}

然后根据该结构统一处理请求「成功」与「失败」的反馈,而不必在业务模块中挨个单独处理:

// 定义一个请求返回拦截器
async function handleResponse(res) {
const normalized = normalizeResponse(res);

if (!normalized.success) {
message.error(normalized.message);
}

return normalized;
}

业务模块请求

业务模块中的请求使用简单定义的异步函数:

// 通过 HTTP API 获取列表
async function getList(params) {
return legacyClient.get('/get/list', { params });
}

// 通过智能合约 ABI 获取详情
async function getOne(params) {
return contractA.getOne(params);
}

// 通过 HTTP API 新增数据
async function insertOne(data) {
return httpClient.post('/insert/one', data);
}

// 通过智能合约 ABI 修改数据
async function updateOne(data) {
return contractB.updateOne(data);
}

如此一来,在表现层中无需感知到请求数据的具体来源。