从React到ArkTS:手把手教你构建模块化简历排版App
如果你想做一个简历排版App,需要用到Preferences模块化存储
想体验一个完整的简历排版应用吗?打开鸿蒙应用市场,搜索「简历坊」下载体验,看看模块拖拽排版、STAR案例库、导出预览这些功能实际用起来是什么感觉。体验完了再回来,咱们一起拆解它是怎么做出来的。
写在前面
简历排版这个需求,说大不大,说小也不小。你要是只做一个固定模板的填表工具,那很简单;但如果你想做成模块化的、可以拖拽调整顺序的、还能导出预览的,那就得好好设计数据结构了。
这篇文章我们以「简历坊」这个App为例,从React的实现开始,然后搬到鸿蒙ArkTS上。重点聊模块化数据结构的设计和拖拽排序的实现。
这篇文章聊什么
- 模块化数据结构设计 – 怎么把简历拆成多个模块,每个模块有独立的类型和数据
- 拖拽排序逻辑 – 怎么实现模块之间的拖拽换位
- Preferences存储 – 怎么把模块化的简历数据持久化保存
第一步:设计模块化数据结构
简历本质上是由多个模块组成的:基本信息、教育经历、工作经历、项目经验、技能列表等等。每个模块有自己的数据格式,模块之间有顺序关系。
React版本
// 简历模块的类型定义
const moduleTypes = {
BASIC_INFO: 'basic_info', // 基本信息
EDUCATION: 'education', // 教育经历
WORK_EXPERIENCE: 'work_exp', // 工作经历
PROJECT: 'project', // 项目经验
SKILLS: 'skills', // 技能列表
SELF_EVAL: 'self_eval', // 自我评价
};
// 一个简历模块的数据结构
const resumeModule = {
id: 'string', // 模块唯一标识
type: 'string', // 模块类型(对应上面的moduleTypes)
title: 'string', // 模块标题,比如"工作经历"
visible: true, // 是否显示
order: 0, // 排序序号
data: {}, // 模块的具体数据,格式根据type不同而不同
};
// 基本信息模块的data格式
const basicInfoData = {
name: '张三',
phone: '13800138000',
email: 'zhangsan@example.com',
address: '北京市朝阳区',
};
// 工作经历模块的data格式(使用STAR法则)
const workExpData = {
company: '某科技公司',
position: '前端工程师',
startDate: '2024-01',
endDate: '至今',
cases: [
{
situation: '公司官网需要重构',
task: '负责前端架构设计和开发',
action: '使用React+TypeScript重构,引入微前端方案',
result: '页面加载速度提升60%,用户留存率提高15%',
}
]
};
你看,每个模块都有一个type字段来区分类型,data字段存放具体数据。不同类型的模块,data的格式不一样。这种设计的好处是:你可以统一管理所有模块(增删改查排序),同时每个模块的数据又是独立的,互不干扰。
ArkTS版本
// 模块类型枚举
enum ModuleType {
BASIC_INFO = 'basic_info',
EDUCATION = 'education',
WORK_EXPERIENCE = 'work_exp',
PROJECT = 'project',
SKILLS = 'skills',
SELF_EVAL = 'self_eval',
}
// STAR案例的数据结构
interface StarCase {
situation: string; // 情境:当时的背景和问题
task: string; // 任务:你需要做什么
action: string; // 行动:你具体做了什么
result: string; // 结果:最终的效果和数据
}
// 工作经历的数据
interface WorkExpData {
company: string;
position: string;
startDate: string;
endDate: string;
cases: StarCase[];
}
// 基本信息的数据
interface BasicInfoData {
name: string;
phone: string;
email: string;
address: string;
}
// 简历模块的通用结构
interface ResumeModule {
id: string;
type: ModuleType;
title: string;
visible: boolean;
order: number;
data: BasicInfoData | WorkExpData | StarCase[] | string; // 联合类型
}
ArkTS版本用了enum来定义模块类型,比字符串常量更规范,编译器能帮你检查类型。data字段用了联合类型BasicInfoData | WorkExpData | StarCase[] | string,表示它可以是多种类型之一。使用的时候需要通过type字段判断具体是哪种类型,然后做类型断言。
你可能会问,为什么不给每种模块类型定义一个单独的interface?比如BasicInfoModule、WorkExpModule之类的。当然可以,但那样管理起来会比较分散。用统一的ResumeModule加联合类型的data字段,在存储和排序时更方便,因为所有模块都是同一个类型。
第二步:拖拽排序的实现
拖拽排序是简历排版App的核心交互。用户长按一个模块,然后拖到另一个位置松手,两个模块就交换了位置。
React版本
function useDragSort(modules, onSort) {
const [dragIndex, setDragIndex] = useState(null);
const [dropIndex, setDropIndex] = useState(null);
const onDragStart = (index) => {
setDragIndex(index);
};
const onDragOver = (e, index) => {
e.preventDefault();
setDropIndex(index);
};
const onDrop = () => {
if (dragIndex !== null && dropIndex !== null && dragIndex !== dropIndex) {
const newModules = [...modules];
// 把拖拽的元素从原位置移除
const [moved] = newModules.splice(dragIndex, 1);
// 插入到目标位置
newModules.splice(dropIndex, 0, moved);
// 更新order字段
newModules.forEach((m, i) => m.order = i);
onSort(newModules);
}
setDragIndex(null);
setDropIndex(null);
};
return { dragIndex, dropIndex, onDragStart, onDragOver, onDrop };
}
React版本的拖拽用的是HTML5原生的Drag and Drop API。onDragStart记录拖拽开始的位置,onDragOver记录当前悬停的位置,onDrop执行实际的排序操作。排序的核心逻辑就是splice:先从原位置取出元素,再插入到目标位置。
ArkTS版本
鸿蒙没有HTML5的Drag and Drop API,但有onTouch事件。我们可以通过触摸事件来实现拖拽效果。
@Entry
@Component
struct ResumeEditorPage {
@State modules: ResumeModule[] = [];
@State dragIndex: number = -1; // 正在拖拽的模块索引
@State dropIndex: number = -1; // 拖拽目标位置索引
private touchStartY: number = 0; // 触摸开始的Y坐标
private isDragging: boolean = false; // 是否正在拖拽
// 触摸开始
onTouchStart(index: number, event: TouchEvent) {
this.touchStartY = event.touches[0].y;
this.dragIndex = index;
this.isDragging = false; // 还没开始拖拽,只是按下了
}
// 触摸移动
onTouchMove(index: number, event: TouchEvent) {
const currentY: number = event.touches[0].y;
const deltaY: number = Math.abs(currentY - this.touchStartY);
// 移动超过20像素才算拖拽,避免误触
if (deltaY > 20) {
this.isDragging = true;
}
if (this.isDragging) {
// 根据当前Y坐标计算目标位置
// 假设每个模块高度约100像素
const targetIndex: number = Math.floor(currentY / 100);
this.dropIndex = Math.max(0, Math.min(targetIndex, this.modules.length - 1));
}
}
// 触摸结束
onTouchEnd() {
if (this.isDragging && this.dragIndex >= 0 && this.dropIndex >= 0
&& this.dragIndex !== this.dropIndex) {
// 执行排序
const newModules: ResumeModule[] = [...this.modules];
const moved: ResumeModule = newModules.splice(this.dragIndex, 1)[0];
newModules.splice(this.dropIndex, 0, moved);
// 更新order
for (let i = 0; i < newModules.length; i++) {
newModules[i].order = i;
}
this.modules = newModules;
// 保存到Preferences
this.saveModules();
}
// 重置状态
this.dragIndex = -1;
this.dropIndex = -1;
this.isDragging = false;
}
}
鸿蒙的触摸事件和Web的touch事件类似,event.touches[0].y获取第一个触摸点的Y坐标。我们通过计算Y坐标的偏移来判断用户想拖到哪个位置。
20像素的阈值是为了区分"点击"和"拖拽"。如果用户只是轻轻点了一下,不应该触发拖拽。只有手指移动超过20像素,才认为是拖拽操作。
Math.max(0, Math.min(targetIndex, this.modules.length - 1))确保目标索引不会越界。Math.floor(currentY / 100)是粗略估算,实际项目中应该根据组件的实际位置来计算。
第三步:用Preferences存储简历数据
简历数据需要持久化保存,这样用户关闭App再打开,数据还在。
React版本
function saveResume(modules) {
localStorage.setItem('resume_modules', JSON.stringify(modules));
}
function loadResume() {
const data = localStorage.getItem('resume_modules');
return data ? JSON.parse(data) : getDefaultModules();
}
// 默认的简历模块
function getDefaultModules() {
return [
{ id: '1', type: 'basic_info', title: '基本信息', visible: true, order: 0, data: {} },
{ id: '2', type: 'education', title: '教育经历', visible: true, order: 1, data: [] },
{ id: '3', type: 'work_exp', title: '工作经历', visible: true, order: 2, data: [] },
{ id: '4', type: 'skills', title: '技能列表', visible: true, order: 3, data: '' },
];
}
ArkTS版本
import { preferences } from '@kit.ArkData';
// 获取默认模块列表
function getDefaultModules(): ResumeModule[] {
return [
{
id: 'mod_1', type: ModuleType.BASIC_INFO, title: '基本信息',
visible: true, order: 0,
data: { name: '', phone: '', email: '', address: '' } as BasicInfoData
},
{
id: 'mod_2', type: ModuleType.EDUCATION, title: '教育经历',
visible: true, order: 1,
data: [] as StarCase[]
},
{
id: 'mod_3', type: ModuleType.WORK_EXPERIENCE, title: '工作经历',
visible: true, order: 2,
data: [] as StarCase[]
},
{
id: 'mod_4', type: ModuleType.SKILLS, title: '技能列表',
visible: true, order: 3,
data: '' as string
},
];
}
// 保存模块数据
async function saveModules(context: Context, modules: ResumeModule[]) {
const store = await preferences.getPreferences(context, 'resume_store');
await store.put('resume_modules', JSON.stringify(modules));
await store.flush();
}
// 加载模块数据
async function loadModules(context: Context): Promise<ResumeModule[]> {
const store = await preferences.getPreferences(context, 'resume_store');
const data = await store.get('resume_modules', '');
if (data === '') {
return getDefaultModules();
}
return JSON.parse(data as string) as ResumeModule[];
}
这里有个细节:默认值的处理。React版本里localStorage.getItem返回null时我们返回默认模块。ArkTS版本里,我们用空字符串''作为默认值(因为Preferences的get方法需要提供一个默认值),然后判断如果data是空字符串就返回默认模块列表。
第四步:STAR案例库
STAR法则是写简历的黄金标准:Situation(情境)、Task(任务)、Action(行动)、Result(结果)。我们做一个案例库,让用户可以积累和管理自己的STAR案例。
ArkTS版本
// 添加一个STAR案例到工作经历模块
function addStarCase(modules: ResumeModule[], moduleId: string, newCase: StarCase) {
const index: number = modules.findIndex(m => m.id === moduleId);
if (index >= 0) {
const moduleData = modules[index].data as WorkExpData;
if (moduleData.cases) {
moduleData.cases.push(newCase);
} else {
moduleData.cases = [newCase];
}
}
}
// STAR案例编辑表单组件
@Entry
@Component
struct StarCaseEditor {
@State situation: string = '';
@State task: string = '';
@State action: string = '';
@State result: string = '';
build() {
Column({ space: 16 }) {
Text('添加STAR案例')
.fontSize(20)
.fontWeight(FontWeight.Bold)
// Situation
Column({ space: 4 }) {
Text('S - 情境(当时的背景和问题)')
.fontSize(14)
.fontColor('#666666')
TextArea({ placeholder: '描述当时的背景...' })
.onChange(value => this.situation = value)
}
// Task
Column({ space: 4 }) {
Text('T - 任务(你需要做什么)')
.fontSize(14)
.fontColor('#666666')
TextArea({ placeholder: '描述你的任务...' })
.onChange(value => this.task = value)
}
// Action
Column({ space: 4 }) {
Text('A - 行动(你具体做了什么)')
.fontSize(14)
.fontColor('#666666')
TextArea({ placeholder: '描述你的行动...' })
.onChange(value => this.action = value)
}
// Result
Column({ space: 4 }) {
Text('R - 结果(最终的效果和数据)')
.fontSize(14)
.fontColor('#666666')
TextArea({ placeholder: '描述最终结果,尽量用数据...' })
.onChange(value => this.result = value)
}
Button('保存案例')
.width('100%')
.type(ButtonType.Capsule)
.onClick(() => {
const newCase: StarCase = {
situation: this.situation,
task: this.task,
action: this.action,
result: this.result,
};
// 这里调用addStarCase保存
})
}
.padding(16)
}
}
STAR编辑器就是一个表单,四个字段分别对应STAR的四个字母。每个字段用TextArea多行输入,因为STAR案例通常比较长,单行输入框不够用。
第五步:完整的简历编辑页面
@Entry
@Component
struct ResumeEditorPage {
@State modules: ResumeModule[] = [];
@State dragIndex: number = -1;
async aboutToAppear() {
this.modules = await loadModules(getContext(this));
}
build() {
Column() {
// 顶部操作栏
Row() {
Text('简历编辑')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.layoutWeight(1)
Button('预览')
.fontSize(14)
.type(ButtonType.Capsule)
.onClick(() => {
// 跳转到预览页面
})
}
.padding(16)
.width('100%')
// 模块列表
List({ space: 12 }) {
ForEach(this.modules, (module: ResumeModule, index: number) => {
ListItem() {
Row() {
// 拖拽手柄
Text('::')
.fontSize(20)
.fontColor('#CCCCCC')
.margin({ right: 12 })
// 模块标题
Text(module.title)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.layoutWeight(1)
// 显示/隐藏切换
Text(module.visible ? '显示' : '隐藏')
.fontSize(12)
.fontColor(module.visible ? '#4ECDC4' : '#999999')
}
.width('100%')
.padding(16)
.borderRadius(12)
.backgroundColor(this.dragIndex === index ? '#FFF3E0' : '#FFFFFF')
.shadow({ radius: 4, color: '#00000010', offsetY: 2 })
.onTouch((event: TouchEvent) => {
if (event.type === TouchType.Down) {
this.onTouchStart(index, event);
} else if (event.type === TouchType.Move) {
this.onTouchMove(index, event);
} else if (event.type === TouchType.Up) {
this.onTouchEnd();
}
})
}
}, (module: ResumeModule) => module.id)
}
.padding(16)
.layoutWeight(1)
// 添加模块按钮
Button('添加模块')
.width('80%')
.margin({ bottom: 20 })
.type(ButtonType.Capsule)
.onClick(() => {
// 显示模块选择弹窗
})
}
.width('100%')
.height('100%')
}
}
这里用List组件替代了之前的ForEach + Column,因为List组件天然支持懒加载,当模块很多时性能更好。ListItem里的onTouch事件处理拖拽逻辑,被拖拽的模块背景色会变成浅橙色,给用户一个视觉反馈。
流程图
React vs ArkTS 对比表
| 功能点 | React (Web) | ArkTS (鸿蒙) |
|---|---|---|
| 数据存储 | localStorage | Preferences |
| 拖拽实现 | HTML5 Drag and Drop API | onTouch触摸事件 |
| 模块类型 | 字符串常量 | enum枚举 |
| 联合类型 | TypeScript union | ArkTS union |
| 列表渲染 | array.map() | List + ListItem |
| 多行输入 | textarea | TextArea组件 |
| 长按检测 | onDragStart | onTouch + 阈值判断 |
总结
这篇文章我们聊了简历排版App的核心实现:
- 模块化数据结构 – 用统一的
ResumeModule接口管理所有模块,type字段区分类型,data字段存放具体数据 - 拖拽排序 – 鸿蒙用
onTouch事件模拟拖拽,通过计算触摸坐标来确定目标位置 - STAR案例库 – 四个字段对应STAR法则,用
TextArea做多行输入
核心API就是Preferences,用来持久化存储模块化的简历数据。如果你对完整的「简历坊」App感兴趣,去鸿蒙应用市场搜索下载体验一下吧。
更多推荐



所有评论(0)