Skip to content
On this page

业务篇-简历制作之数据的录入与展示


前言

上一章节我们实现了点击工具条模块,显示弹窗,弹窗内通过 MyInput 组件展示当前的数据内容,本章节我们将实现简单数据录入与复杂数据的录入。如果你忘记了这块的内容知识,请回到 设计篇-需求功能设计与数据存储方案设计重温一下知识。

本章节篇幅较长,但属于项目中的重中之重,希望你能耐心往下看

useUpdateResumeHook

数据录入的本质是获取修改后的内容值,通过 dispatch 一个 Action,将 redux 中对应的数据替换,即可完成录入功能。

前面讲到组件颗粒化,事实上我们也是按照颗粒化思想去拆分组件的,到这你会发现,每个组件都履行着各自职责,此时如果对简历数据进行修改,最终的结果是:修改逻辑均散落在各自组件中。动动我们的小脑袋瓜,我们是否可以封装一个通用方法,所有修改简历数据都只能通过此方法,传入需要修改的 key 和 newValue,才能操作 redux 修改数据值?于是 useUpdateResumeHook 诞生了。

我们在 ResumeContent 文件夹下,新增一代码文件,取名为:useUpdateResumeHook.ts,下面以修改个人基本信息为例,编写我们的相关代码

ts
// app/renderer/container/resume/ResumeContent/useUpdateResumeHook.ts
import { useSelector, useDispatch } from 'react-redux';

/**
 * @description 更新简历信息,这是修改 redux 简历信息的唯一方法
 * @param {string[]} stateKey 关键key,如路径为 [base/username] 表示修改 base 对象下的 username
 * @param {string} stateValue
 */
function useUpdateResumeHook() {
  const updatePersonalHook = useUpdatePersonalHook();
  return <T>(stateKey: string, stateValue: T) => {
    const keys = stateKey.split('/') || [];
    if (keys[0]) {
      if (keys[0] === 'base') updatePersonalHook(keys[1], stateValue);
    }
  };
}

/**
 * @description 修改个人信息(base)
 */
function useUpdatePersonalHook() {
  const dispatch = useDispatch();
  const base: TSResume.Base = useSelector((state: any) => state.resumeModel.base);
  return <T>(stateKey: string, stateValue: T) => {
    dispatch({
      type: 'resumeModel/setStore',
      payload: {
        key: 'base',
        values: {
          ...base,
          [stateKey]: stateValue,
        },
      },
    });
  };
}

export default useUpdateResumeHook;

以上就完成了个人基本信息模块的更新(如何使用该 Hook 请往下看),更多模块信息的 hook 实现,小伙伴们可前往 github 阅读相关代码 👉 chapter-11 useUpdateResumeHook

简单数据录入

怎样才能被称为简单数据?在我们定义的简历数据类型约束上,属于简单数据类型如姓名、邮箱、电话等相关字段,在这我将它归为简单数据。

下面我们举几个例子,来实现简单数据的录入。

简历头像上传

我们进入 /UseTemplate/templateOne/components/Avatar 组件,通过已封装好的 <ImageUpload /> 组件实现选中图片,该组件的 onAfterChange 事件会抛出一个文件数组,数组第一项就是我们的图片文件信息。通过调用 useUpdateResumeHook 进行修改简历头像链接地址(下面是伪代码)

ts
/**
 * @desc 头像
 * @author pengdaokuan
 * @link container/resume/ResumeContent/UseTemplate/templateOne/components/Avatar/index.tsx
 */
import useUpdateResumeHook from '@src/container/resume/ResumeContent/useUpdateResumeHook';

function Avatar() {
  const updateResumeHook = useUpdateResumeHook();
  const base: TSResume.Base = useSelector((state: any) => state.resumeModel.base);

  // 👇 更新用户的简历头像
  const onUpdateUserAvatar = (avatarUrl: string) => {
    updateResumeHook<string>('base/avatar', avatarUrl);
  };

  return (
    <div styleName="box">
      {!base?.avatar && (
        <ImageUpload
          icon={uploadIcon}
          accept="image/*"
          multiple={false}
          onAfterChange={(files: TSUpload.File[]) => {
            onUpdateUserAvatar(files[0]?.base64URL);
          }}
        />
      )}
      {base?.avatar && (
        // 👉 展示我们的头像...(伪代码,省略)
      )}
    </div>
  );
}

export default Avatar;

个人信息数据录入

还记得上一章节我们添加了许多表单弹窗组件吧?我们选择进入个人信息的表单弹窗,赐予它修改简历数据的力量。我们来修改相关代码(下面是伪代码)

ts
/**
 * @description 个人信息Form
 * @author pengdaokuan
 * @link /container/resume/ResumeContent/UseForm/Personal/index.tsx
 */
import useUpdateResumeHook from '@src/container/resume/ResumeContent/useUpdateResumeHook';

function Personal() {
  // 👇 唯一能修改我们简历数据的方式
  const updateResumeHook = useUpdateResumeHook();
  const hobby: string = useSelector((state: any) => state.resumeModel.hobby);
  const base: TSResume.Base = useSelector((state: any) => state.resumeModel.base);

  return (
    <MyModal.Dialog>
      <div styleName="form">
        <div styleName="flex">
          <div styleName="left">
            <span styleName="require">*</span> 
          </div>
          <div styleName="right">
            <MyInput
              onChange={(e) => {
                // 👇 修改个人基本信息中的姓名字段数据
                updateResumeHook('base/username', e.target?.value || '');
              }}
              value={base?.username || ''}
              placeholder="请输入姓名"
              allowClear={true}
            />
          </div>
        </div>
      </div>
    </MyModal.Dialog>
  );
}

export default Personal;

更多简单数据录入

《论语·述而》有记载:举一隅不以三隅反,则不复也。当你掌握了简单数据录入的核心流程之后,对于同数据类型的修改,对你而言不是什么大问题了。

无非就是引入 useUpdateResumeHook,然后在 MyInput 输入框组件的 onChange 事件中,调此 hook,传入正确的参数,即可实现简历数据的更新录入。

对于更多简单数据的录入,这里将不再叙述,小伙伴们可私下独自完成或配合代码加以实现。

复杂数据录入

怎样才能被称为复杂数据?项目经验、工作经验、在校经验等,我们很容易就能想到,这些肯定不是一个简单数据类型,他们是一个数组,数组中的每一项对应一次“非凡”的体验;

设计篇为大家中,已经明确通过类似“文档笔记”的交互效果实现复杂数据录入。我们再来看看效果图

image.png

项目经验数据录入

我们将在这有限的弹窗空间中,做一些有趣的事情,先看看组件分层图

image.png

从图中我们可以很快知道,以弹窗内容区域为主,向左划分部分区域,用于条目列表的展示,以内容编辑为利器,占领右侧,两者结合,实现最佳效果。

小伙伴们看完这图,不要着急去写代码,让我们再想想,如何开发会更好,毕竟可预见的后果是:工作经验、在校经验等也是一样的展示效果和交互体验,后人哀之而不鉴之,亦使后人而复哀后人也!设计如果不够好,则后期开发相同模块时会大大提高代码量和使用成本。

我们要明白,这里面的核心思想:变与不变

  • 何为变?上图中变的是左侧列表条目展示的数据源,表单内容信息的展示(也能称之为数据源)
  • 谁能不变?条目的布局定位,UI 效果,交互效果,删除条目、新增条目、切换条目等这些都不会随着我们的数据源改变而改变。

我期望:业务开发过程中,只需要传入数据源 dataList 与表单组件 Form,就能达到我想要的效果。

我目前尝试过三种方案,下面给小伙伴们简单聊一下我的想法

方案一:不通用,各做各的

意思就是我们不将通用的逻辑抽出来(切换条目、删除条目等),等价于每个模块对应一套 EditorForm,如项目经验,就叫 ProjectEditorForm,工作经历就称为 WorkEditorForm。

该方案好处就是,各模块之间互不影响,代码阅读上相对直观;缺点就是存在重复代码逻辑,同时在将来相似模块,使用成本较大。

方案二:HOC 高阶组件

核心思想是:封装一个高阶组件 ExperienceHoc,之后所有的 Form 都通过高阶组件进行组合

该方案好处就是,所有逻辑如切换条目、删除条目等都抽离到 Hoc 处理,Form 组件只需展示当前选中的条目数据与修改当前条目数据。但问题在于,我们的 Form 组件展示的数据是从 Hoc 传递而来,也许这么讲不够形象,下面结合图片与伪代码,帮助大家理解一下。

image.png

图一大家能直观看懂,我们来说图二,从数据层面上看,我们在业务组件 Form 中,直连 Redux,然后将数据源 dataList 传给 ExperienceHoc,并定义一个方法,即数据修改之后的回传方法 updateDataList。我们的 Form 组件需展示当前选中的某条 Item 数据,而当前选中哪条数据是在 Hoc 中操作,然后传递给 Form 组件的,所以需要在 Hoc 中给我们的 Form 组件注入数据源。最终我们的代码如下所示。一定要看注释!!!

下面是 ExperienceHoc 的伪代码

ts
// 编辑器 HOC
const ExperienceHoc = (WrappedComponent: React.ComponentType | any) => ({ dataList, updateDataList }: HocProps) => {
  return class extends React.Component {
    constructor() {
      this.state = {
        currentItem: null,
      };
    }

    // 👇 1. 一顿逻辑操作,给 currentItem 赋值,表示当前选中的条目,代码略过
    // 👇 2. 给 Form 组件注入该数据源!!!!
    getProps = () => ({
      ...this.props,
      currentItem: this.state.currentItem,
      onChangeCurrentItem: this.onChangeCurrentItem
    });
     // 👇 3. Form 组件中修改当前条目数据源
    onChangeCurrentItem = () = {
      // 当条数据源更新,同步更新整个数组,执行updateDataList方法!!!!!!
    }

    render() {
      return (
        <div styleName="editor-box">
          <WrappedComponent {...this.getProps()} />
        </div>
      );
    }
  };
};

接下来我们看看业务组件 Form 如何使用该高阶组件

ts
// 项目经验的 Form
import { useSelector } from 'react-redux';
// 👇 1. 引入高阶组件
import ExperienceHoc from '../ExperienceHoc';
import MyInput from '@common/components/MyInput';
// 👇 2. 引入修改简历数据的 Hooks
import useUpdateResumeHook from '@src/container/resume/ResumeContent/useUpdateResumeHook';
// 👇 3. 这里需要定义接收的 Props,事实上,这些 props 是高阶组件传的
function ProjectForm({ currentItem, onChangeCurrentItem }) {
  const updateResumeHook = useUpdateResumeHook();
  const projectExperience = useSelector((state: any) => state.resumeModel.projectExperience);

  return (
    <MyInput
      onChange={(e) => {
        onChangeCurrentItem(e.target.value);
      }}
      value={currentItem?.projectName}
      placeholder="请输入项目名"
      allowClear={true}
    />
  );
}

// 👇 传递数据源
export default ExperienceHoc(ProjectForm)({
  dataList: projectExperience,
  updateDataList: (newDataList) => updateResumeHook(newDataList),
});

这种方式虽然也能实现我们的功能,但我在想是否还能改变一下写法?

方案三:render Props 混合嵌套

其实本质上,无论用什么方案,我们都避免不了一点的是:往 Form 组件上注入 props,这是必须的,只是在编写代码的时候,哪种方式更加优雅,我们希望的是把非业务相关的代码隔离出去,我们只在乎 Form 组件里的数据展示和更改。

下面是我想到的另一方式,先看代码,我们封装一个通用组件,暂且称为:WrapperExperience

ts
// ResumeContent/UseForm/WrapperExperience/index.tsx
function WrapperExperience({ children, dataList, updateDataList }: IProps) {
  const [currentIndex, setCurrentIndex] = useState(0);
  const [currentItem, setCurrentItem] = useState(null);
  // 👇 1. 内部维护 currentIndex,根据索引从数组 dataList 中获取数据
  // 一顿逻辑操作,给 currentItem 赋值,表示当前选中的条目,代码略过

  // 👇 2. 定义 Form 组件中修改当前条目数据源的方法
  const onChangeCurrentItem = useCallback((newValue = {
    // 当条数据源更新,同步更新整个数组,执行updateDataList方法!!!!!!
  }), [currentItem]);

  const newChildren = useMemo(() => {
    return React.Children.map(children, (child) => {
      if (React.isValidElement(child)) {
        // 👇 3. 核心在于,给子组件注入两个属性:当前条目与修改当前条目的方法
        return React.cloneElement(child, {
          currentItem: currentItem,
          onChangeCurrentItem: onChangeCurrentItem,
        });
      }
      return child;
    });
  }, [children, dataList]);

  return <div styleName="wrapper">{newChildren}</div>;
}

export default WrapperExperience;

在业务使用上,也极为方便

ts
import MyModal from '@common/components/MyModal';
import Form from './Form';
import WrapperExperience from '../WrapperExperience';
import useUpdateResumeHook from '@src/container/resume/ResumeContent/useUpdateResumeHook';

function ProjectExperience({ onClose }: IProps) {
  const updateResumeHook = useUpdateResumeHook();
  const projectExperience = useSelector((state: any) => state.resumeModel.projectExperience);

  const updateDataList = (newDataList: any[]) => updateResumeHook(newDataList);

  return (
    <MyModal.Dialog title="项目经验">
      <WrapperExperience dataList={projectExperience} updateDataList={updateDataList}>
        <Form />
      </WrapperExperience>
    </MyModal.Dialog>
  );
}

export default ProjectExperience;

在 Form 组件上,通过 props 获取数据即可。

ts
function Form({ currentItem, onChangeCurrentItem }: IProps) {}

如果有更好方案的小伙伴,可以在评论区中留言,我们一起探讨~

其实该方案与 ExperienceHoc 高阶组件的方案并没有什么区别,核心还是给 Form 注入参数和方法,下面我将采用方案三,实现我们复杂数据的录入。

功能一:适配器

怎么理解呢?在简历数据的类型定义上,存在兴许的差异,比如:

ts
interface SchoolExperience extends Experience {
  department?: string; // 部门
}
interface ProjectExperience extends Experience {
  projectName?: string; // 项目名
}
interface ProjectExperience extends Experience {
  companyName?: string; // 公司名
}

但是在条目列表中,它都属于 条目标题(能理解吗?小伙伴们),所以需要做成适配,将它适配成条目所需要的接口数据(适配器模式)下面我们实现一下适配器,具体代码在这里:👉 adapter experience

ts
// 具体代码前往GitHub阅读
const AdapterExperience = {
  /**
   * @description 项目经验
   */
  project(list: TSResume.ProjectExperience[]): AdapterExperienceType[] {},
  /**
   * @description 工作经验
   */
  work(list: TSResume.WorkExperience[]): AdapterExperienceType[] {},
  /**
   * @description 在校经验
   */
  school(list: TSResume.SchoolExperience[]): AdapterExperienceType[] {},
};

export default AdapterExperience;

在业务组件,只需要调用适配器进行转一下数据结构即可。

ts
// 项目经验
function ProjectExperience({ onClose }: IProps) {
  const updateResumeHook = useUpdateResumeHook();
  const projectExperience = useSelector((state: any) => state.resumeModel.projectExperience);

  const updateDataList = (newDataList: AdapterExperienceType[]) => {
    // 👉 该数据为操作之后的最新数据源,将该数据进行操作并存入 Redux
  };

  return (
    <MyModal.Dialog title="项目经验">
      <Wrapper
        // 👇 数据经过适配器进行组件数据的适配
        dataList={AdapterExperience.project(projectExperience)}
        updateDataList={updateDataList}
      >
        <Form />
      </Wrapper>
    </MyModal.Dialog>
  );
}

功能二:添加条目

添加条目不困难,只是需要考虑的一点是:编辑态下的新增条目,需要如何处理?

image.png

ts
const onAddItem = () => {
  // 1. 如果当前属于编辑态
  if (editModal.status) {
    onToggleEditModal({
      showByCancel: true, // 当取消编辑内容,弹窗显示
      onAfterFn: () => { // 确定取消,则新增条目
        const newList = onAddExperience(experienceList);
        if (newList.length > 0) {
          // 定位激活刚添加的这条数据
          setCurrentIndex(0);
          setExperienceList(newList);
          updateDataList && updateDataList(newList);
        }
      },
    });
  } else {
    // 2. 不属于编辑态
    const newList = onAddExperience(experienceList);
    if (newList.length > 0) {
      // 定位激活刚添加的这条数据
      setCurrentIndex(0);
      setExperienceList(newList);
      updateDataList && updateDataList(newList);
    }
  }
};

功能三:切换条目

同上,我们切换条目时,并不复杂,唯一需要做的是:编辑态下的切换条目,如何处理?

image.png

ts
const onChangeItem = useCallback(
  (index: number) => {
    // 5.1 当前正在编辑状态
    if (editModal.status) {
      onToggleEditModal({
        showByCancel: true, // 当取消编辑内容,弹窗显示
        onAfterFn: () => { // 确定取消,则新增条目
          setCurrentIndex(index);
        },
      });
    } else {
      setCurrentIndex(index);
    }
  },
  [editModal]
);

功能四:删除条目

删除之前,一样需要对编辑态的判断

image.png

ts
// 1. 点击删除条目
const onDeleteItem = (index: number) => {
  setDeleteModal({
    show: true,
    deleteIndex: index,
  });
};
// 2. 删除弹窗的取消按钮回调
const onDeleteCancel = useCallback(() => {
  setDeleteModal({
    show: false,
    deleteIndex: -1,
  });
}, [currentIndex, deleteModal]);
// 3. 删除弹窗的确定按钮回调
const onDeleteOk = useCallback(() => {
  const newList = onDeleteExperience(deleteModal.deleteIndex, experienceList);
  if (newList.length > 0) setCurrentIndex(0);
  else setCurrentIndex(-1);
  setDeleteModal({
    show: false,
    deleteIndex: -1,
  });
  setExperienceList(newList);
  updateDataList && updateDataList(newList);
}, [currentIndex, deleteModal]);

功能五:编辑条目

这里很有意思的一点是:编辑态下的输入框可输入,非编辑态下的输入框处于 disabled 禁止状态。

image.png

image.png

对当前正编辑的条目来说,只有当点击“保存”之后,才能将编辑后的数据更新至数据源,故而需要一个临时值缓存编辑后的数据。

如果不这么做,会导致一个 Bug : 编辑后的数据直接在数据源中更新,当点击“取消”按钮并确定放弃当前编辑的内容时。我们期望此次的内容并不被更新进数据源,但实际上,当前编辑的内容早已被同步修改到数据源了。

ts
// 修改当前条目内容
const onChangeCurrentItem = useCallback((newItem: AdapterExperienceType) => {
  // 👇 临时存储当前编辑的内容数据
  onToggleEditModal({
    tempSaveItem: { ...newItem },
  });
  setCurrentItem(newItem);
}, [children, onToggleEditModal]);

// 当点击“保存”按钮时触发
const onSaveEditValue = useCallback(() => {
  let newList = [...experienceList];
  let item = editModal?.tempSaveItem
    ? { ...editModal?.tempSaveItem }
    : { ...currentItem };
  newList[currentIndex] = item;
  setExperienceList(newList);
  updateDataList && updateDataList(newList);
  onToggleEditModal({
    status: false,
  });
}, [editModal?.tempSaveItem, currentIndex, onToggleEditModal]);

功能六:数据同步 Redux

我们通过 updateDataList 将最新的数据返回给业务层,我们只需要在业务层将数据同步到 Redux 即可,下面看看代码

ts
// 👇 调用写好修改 redux 的 hooks 操作修改项目经验
const updateDataList = (newDataList: AdapterExperienceType[]) => {
  updateResumeHook<AdapterExperienceType[]>('projectExperience', newDataList);
};

简历数据在模版上同步展示

上面完成了简单数据和复杂数据的录入,接下来我们需要将录入的数据在简历模版上进行展示。

进入 /ResumeContent/UseTemplate/templateOne 组件下,我们对每个组件都直连 redux 取对应数据值,对数据进行判断,下面展示联系方式部分代码

ts
/**
 * @desc 联系方式
 * @author pengdaokuan
 */
import { useSelector } from 'react-redux';

function Contact() {
  const contact: TSResume.Contact = useSelector((state: any) => state.resumeModel.contact);
  return (
    <div styleName="container">
      <p styleName="title">联系方式 Contact</p>
      <ul styleName="content">
        {contact?.phone && <li>电话{contact?.phone}</li>}
        {contact?.email && <li>邮箱{contact?.email}</li>}
      </ul>
    </div>
  );
}

export default Contact;

总结

本章节主要给大家介绍在项目中,简单数据与复杂数据的录入,具体逻辑小伙伴们直接去阅读相关代码,看代码会更加直观。

相关代码:👉 WrapperExperience

更多的是在实现上的思考,当然也不一定代表我的设计是正确的,有更好的欢迎小伙伴们提出,我们一起探讨。

如果您在边阅读边实践时,发现代码报错或者 TS 报错,那么小伙伴们可以根据报错信息,去线上看看相应的代码。

如果对本章节存在疑问,欢迎在评论区留言。