Loading...

# 前言

在上一章节,我们已将数据存储的功能实现

在简历制作之前,我想还是有必要单独写篇文章讲解一下常用组件的封装设计与数据字段设计,大家可能喜欢如上几章节的写作思路:先 “粗暴编写” 再 “思考优化”,但在此章节中,我们需要稍微改变一下思考的方式,不要担心,先往下看。

# 组件化思想

必须承认一点是:人的精力与能力是有限的,你是很难一次性处理好一大堆复杂问题的。但我们与生具备的一优秀特点,那就是拆解问题。如同写代码一样,我们将所有的处理逻辑均放在一个组件中,那么后续的维护、管理及扩展将会变得困难,我们要学会去 “拆” 逻辑、“拆” 组件。

React 核心思想是组件化,它期望我们通过拆解小颗粒化的组件,进行拼接,从而构造我们的应用。假设我们在一个组件中做完所有的事情,那这个组件属于多职责组件,它不需要区分各种职责,不用规划对应的结构。最终的结果导向为:定位问题时间成本相对较高,代码阅读上,极为痛苦。React 哲学里很明确的说道:组件应当遵循单一功能原则,换言之,一个组件原则上只能负责一个功能。

颗粒化拆分组件,需要思考怎样的颗粒度才合适。粒度不是越小越好,粒度最小太极端,会导致小型组件很多,管理困难。所以这个颗粒,一定是最适合被复用的程度。

# 组件封装

接下来将会讲解目前简历应用平台的通用组件(当然随着业务开发,可能会越来越多),这里并不会贴代码实现,只会讲解其中的思考过程和为什么要封装。对于组件具体代码在 github 仓库里,可自行前往阅读查看。

组件封装为了更好书写样式名,这边采用 classnames 库进行处理

npm install classnames --save

说白了,我们可以不用封装通用组件,所有的组件都可称之为 “业务组件”,比如你点击了 “导出” 按钮后,显示弹窗,弹窗底部有两个按钮:确定按钮、取消按钮。

我们可以统称这三个按钮为业务按钮组件,每一个按钮对应自己的业务,自己的逻辑。这是合理的,只是我们自身认为不合理的地方是:他们有很多共性,在差异点上可能就文案的不同,颜色的不同,其余的交互效果一致(比如鼠标 hover 按钮、点击按钮之后的颜色改变等)正因为这些一致,在 “下一次” 新增业务组件时,我们都手动拷贝一份代码,这会导致项目中存在大量 “重复” 代码。正因为如此,我们才认为它是不合理的,也正因为这样,我们才要去封装公共组件。

所有的通用组件均存放于 app/renderer/common/components 中,通用组件共有:

  • MyButton 按钮组件
  • MyInput 数据输入组件
  • MyUpload 文件上传组件
  • MyModal 弹窗组件
  • MyScrollBox 固定区域内的滚动组件

# MyButton

组件代码地址:👉 查看

之前看过一篇关于按钮组件的文章: Button 组件

image.png

<MyButton size="middle" onClick={() => console.log('点击按钮')}>
  导出PDF
</MyButton>

# MyInput 组件

组件代码地址:👉 查看

在制作简历过程中,最重要的是用户信息的输入,我们可以通过 HTML 提供的 input 元素加以实现,这会造成的问题是:

  • 在组件中大量编写 input 代码
  • 需要写一大段的 css 代码加以覆盖原生样式
  • 可能需要重复编写一些额外操作功能的样式代码,如清空输入内容

当一个东西重复出现,在交互、样式上都基本一致,那么我们就需要思考斟酌一下:能否做成通用?

其次对于内容的输入,除 input 外,我们还会使用 textarea 实现,它们最直观的区别莫过于单行文本与多行文本的差异。我们思考一下,能否将两种进行合并,在 MyInput 组件中实现各自的逻辑功能,业务端使用时,不需要根据场景去编写对应的处理逻辑,仅通过一个 type 属性就能得到对应的组件效果。

  • 单行输入框
<MyInput
  value={base.username}
  placeholder="请输入姓名" // 占位文本
  allowClear={true} // 是否显示清除 icon
  onChange={(e) => console.log(e.target.value)}
/>
  • 多行输入框
<MyInput
  value={hobby || ''}
  placeholder="你有什么特长爱好呢" // 占位文本
  allowClear={true} // 是否显示清除 icon
  onChange={(e) => console.log(e.target.value)}
  type="textarea" // 类型为多行文本
  rows={5} // 输入文本的行数
  maxLength={200} // 最多支持的文本长度
  allowCount={true} // 是否显示底部文本字数
/>

# MyUpload

组件代码地址:👉 查看

我们期望能给 HR 留下一个良好的第一印象,照片是最能体现的一个人的精神面貌,所以我们会存在一个简历头像的上传功能,那么问题点在于:如何实现本地文件上传并显示

经验老道的程序员第一反应就是: <input type="file" accept="image/*" /> ,很快的,第一个版本的 <MyUpload /> 组件实现了,如你所想,该组件职责就是用于图片上传。

随即带来了问题,由于我们写死了 accept="image/*" ,假设将来有其他资源的文件上传,该怎么办?当然有很多解决方案,这里我采用的解决方案是:封装基础的上传组件,基于此组件衍生出图片类型的上传组件,将来如果有其他资源类型的上传组件,只需要衍生即可。

image.png

会不会有小伙伴存在疑问:为什么不将该 Upload 组件做得更加通用,所有东西都从由业务通过 props 决定呢?之所以没这么设计的原因在于:

  • 部分逻辑要在业务端处理
  • 样式 UI 的高度复用

举个例子,默认的 input 样式并不美观,

而往往我们都会自己实现一套 UI 样式,假设我们需要多种模板

如果在模版一是这种效果,是不是我得在模版一中实现这个 UI 样式(写一坨 CSS),那模版二呢?模版三呢?包括选择文件之后,隶属于文件处理的部分逻辑,是不是也需要在业务端处理呢?

这很好理解,我举个例子:当我选择一张图片之后,需要得到文件名、文件类型、文件大小,这些需要通过工具函数处理才能得到,这块逻辑放于业务层去处理,这属于业务层的工作吗?小伙伴们细品细品。

资源上传中还实现了一个 FileEvent 类,具体实现如下

class FileEvent {
  public constructor(file: any) {
    this.file = file;
    this.uuid = createUID();
    const types = file?.type?.split('/') || [];
    this.fileType = types.length ? types[0] : '';
    this.base64URL = window.URL.createObjectURL(file); // 本地预览地址
  }
  // 释放创建过的 URL,不然会存在性能问题
  // 详情可见 : https://developer.mozilla.org/zh-CN/docs/Web/API/URL/createObjectURL
  public revokeFileBase64URL(base64URL: string) {
    window.URL.revokeObjectURL(base64URL);
  }
  // 上传 / 取消上传 / 重试
  public upload() {}
  public cancel() {}
  public retry() {}
}
export default FileEvent;

# MyModal

组件代码地址:👉 查看

/**
 * @description 所有弹窗组件集合
 * 方式一:
 * import MyModal from '@components/MyModal';
 * <MyModal.Confirm/>
 *
 * 方式二:
 * import {Confirm} from '@components/MyModal';
 * <Confirm />
 */
import MyDialog from './MyDialog';
import MyConfirm from './MyConfirm';
export const Dialog = MyDialog;
export const Confirm = MyConfirm;
export default {
  Dialog: MyDialog,
  Confirm: MyConfirm,
};

而在业务中可以很简单的使用

<MyModal.Confirm
  title="确定要打印简历吗?"
  description="请确保信息的正确,目前仅支持单页打印哦~"
  config=<!--swig0-->
/>

# MyScrollBox

组件代码地址:👉 查看

该组件的职责功能是:在给定的一个最大高度内,超出高度滚动展示。

我们常常会有一些交互效果是给容器定个最大高度,如果展示内容超出此高度,则在此容器内进行滚动,但往往我们会出现默认的滚动条,及其不美观,于是在去掉滚动条的基础上进行组件封装,从而达到我们期望的效果。下面是业务中的使用

import MyScrollBox from '@common/components/MyScrollBox';
function Resume() {
  const HEADER_HEIGHT = 60;
  const height = document.body.clientHeight;
  return (
    <div styleName="container">
      <MyScrollBox maxHeight={height - HEADER_HEIGHT}>
        <Template.TemplateOne />
      </MyScrollBox>
    </div>
  );
}

# 简历数据设计

一份简历最为重要的莫过于数据字段的设计,在说字段设计之前,我们往 redux 中添加一份简历信息的 model,进入 app/renderer/store 文件夹中,新增一份代码文件,取名为: resumeModel ,然后将其添加到 reducerList 中。

这边通过 TSRcReduxModel 与 TSResume 对其进行了类型约束,可前往 types 查看

import logger from 'redux-logger';
import RcReduxModel from 'rc-redux-model';
import { createStore, applyMiddleware, combineReducers } from 'redux';
// 👇 引入我们写好的 model
import globalModel from './globalModel';
import resumeModel from './resumeModel';
// 👇 这里只需要调用 RcReduxModel 实例化一下得到最后的 reduxModel
const reduxModel = new RcReduxModel([globalModel, resumeModel]);
// 👇 无侵入式的使用 Redux,即使你写最原始的 reducer 也照样支持
const reducerList = combineReducers(reduxModel.reducers);
export default createStore(reducerList, applyMiddleware(reduxModel.thunk, logger));

接下来讨论一下简历数据有哪些字段吧?如果按照模块来分,我们是否能划分出下面几大模块?

  • 基本信息
  • 联系方式
  • 求职意向
  • 技能清单
  • 个人评价
  • 荣誉证书
  • 在校经验
  • 工作经验
  • 项目经验

模块划分出来后,剩下的就好办了,下面是一份完整的简历数据格式,关于类型定义可看 resume.d.ts

const userResume = {
  base: {
    avatar: '',
    username: '',
    area: '',
    school: '',
    major: '',
    degree: '',
    hometown: '',
    onSchoolTime: {
      beginTime: '',
      endTime: '',
    },
  },
  contact: {
    phone: '',
    email: '',
    github: '',
    juejin: '',
  },
  work: {
    job: '',
    city: '',
    cityList: [],
  },
  hobby: '',
  skill: '',
  skillList: [
  ],
  evaluation: '',
  evaluationList: [
  ],
  certificate: '',
  certificateList: [''],
  schoolExperience: [
    {
      beginTime: '',
      endTime: '',
      post: '',
      department: '',
      content: '',
      parseContent: [
        
      ],
    },
  ],
  workExperience: [
    {
      beginTime: ,
      endTime: ,
      post: '',
      department: '',
      content: '',
      parseContent: [
      ],
    },
  ],
  projectExperience: [
    {
      beginTime: '',
      endTime: '',
      projectName: '',
      post: '',
      content:
        '',
      parseContent: [
      ],
      date: 1621145137865,
    },
  ],
};