Skip to content
On this page

业务篇-简历制作之工具条模块与简历模版之间通信


工具条交互功能优化

上一章节我们仅是完成了简历制作入口的静态页面,一次性展示所有的工具条模块,其实这种交互是比较呆板僵硬的,接下来我们对它进行一个优化。

本章节篇幅较长,但也相对重要,其中代码块均以伪代码实现,具体代码实现移步 chapter-10

我们将所有的工具条模块进行区分:已添加模块未添加模块。怎么理解呢?我画几张图进行解释。

我们的简历模版信息是与 “已添加模块” 同步的,如下图所示,“已添加模块”中有:个人信息、技能清单、联系方式等,那么在模版上,也只会展示这三个模块的数据信息。

image.png

当我们点击“获奖证书”后,将其加到“已添加模块”中,此时简历模板会同时展示获奖证书对应的数据。同理,当我们删除“已添加模块”中的某个模块后,简历模版上不展示删除的模块信息。

image.png

了解交互效果后,接下来我们来实现功能。透过问题看本质,让我们回到简历制作最开始的阶段:一张空白的 A4 纸,所有模块都是未添加的状态,此时此刻,我们的简历模板什么数据都不展示。

所以我们得到一个有效信息:所有工具条模块都是未添加的,但我们期望,“个人信息”模块是默认加入的,为什么呢?简历最重要的是什么?是你的工作经验还是你的技能清单?是你在学校的优秀表现还是你做了多少牛逼的项目?都不是,最重要的往往是最基础的,也就是个人基础信息(姓名、电话等)。

如何区分已添加与未添加呢?让我们修改前边写的工具条数据,修改 /constants/resume.ts 文件

ts
// app/renderer/common/constants/resume.ts
const RESUME_TOOLBAR_LIST: TSResume.SliderItem[] = [
  {
    key: RESUME_TOOLBAR_MAPS.personal,
    name: '个人信息',
    require: true, // 👈 添加该字段,为 true 则表示必选项
    summary: '更好介绍自己,机会会更多',
  },
  {
    key: RESUME_TOOLBAR_MAPS.education,
    name: '教育信息',
    require: false, // 👈 添加该字段,为 true 则表示必选项
    summary: '介绍你的学校和专业信息',
  },
  // ...
];

export default RESUME_TOOLBAR_LIST;

我们对每条数据添加了一个 require 属性,表示这个模块在初始时是否默认追加到“已添加模块”中,接下来我们将 ResumeToolbar 的 index.tsx 文件进行改造(下面为伪代码)

ts
function ResumeToolbar() {
  // ...
  // 👇 定义已添加模块与未添加模块
  const [addToolbarList, setAddToolbarList] = useState<TSResume.SliderItem[]>([]);
  const [unAddToolbarList, setUnAddToolbarList] = useState<TSResume.SliderItem[]>([]);

  // 👇 在生命周期中,根据 require 字段,分别加入对应的数据源
  useEffect(() => {
    if (RESUME_TOOLBAR_LIST.length > 0) {
      let _addToolbarList: TSResume.SliderItem[] = [];
      let _unAddToolbarList: TSResume.SliderItem[] = [];
      RESUME_TOOLBAR_LIST.forEach((s: TSResume.SliderItem) => {
        if (s.require) _addToolbarList.push(s);
        if (!s.require) _unAddToolbarList.push(s);
      });
      setAddToolbarList(_addToolbarList);
      setUnAddToolbarList(_unAddToolbarList);
    }
  }, []);

  return (
    <div styleName="slider">
      <MyScrollBox maxHeight={height - 180}>
        {!!addToolbarList.length && (
          <div styleName="module">
            // 已添加模块
            <div styleName="content">
              {addToolbarList.map((addSlider: TSResume.SliderItem) => {
                // 遍历展示
              })}
            </div>
          </div>
        )}
        {!!unAddToolbarList.length && (
          <div styleName="module">
            // 未添加模块
            <div styleName="content">
              {unAddToolbarList.map((unAddSlider: TSResume.SliderItem) => {
                // 遍历展示
              })}
            </div>
          </div>
        )}
      </MyScrollBox>
    </div>
  );
}

下图为实现效(左侧一片黑是因为我将简历模板隐藏,为了直观突出工具条效果)

image.png

从未添加到已添加实现

接下来我们需要实现,当点击“未添加模块”中的某条 Item 时,将其添加到“已添加模块”,并同时把“未添加模块”中点击的 Item 删除。如下图所示,下图是一个添加过程。

image.png

我们来编写代码,为每一条未添加的模块注册一个 onClick 事件,具体实现看下面代码

ts
// 添加模块
const onAddSliderAction = (moduleToolbar: TSResume.SliderItem) => {
  // 1. 获取已添加模块的所有 key 值
  const addKeysList = addToolbarList.map((s: TSResume.SliderItem) => s.key);

  let nextAddToolbarList = [...Array.from(addToolbarList)];
  // 2. 如果未包含当前要添加的模块key,则加入
  if (!addKeysList.includes(moduleToolbar.key)) {
    nextAddToolbarList = nextAddToolbarList.concat(moduleToolbar);
  }
  setAddToolbarList(nextAddToolbarList);

  const nextUnAddToolbarList = [...Array.from(unAddToolbarList)];
  // 3. 如果在未加添加模块中还存在此模块key,则删除
  const findIndex = nextUnAddToolbarList.findIndex((s) => s.key === moduleToolbar.key);
  if (findIndex > -1) nextUnAddToolbarList.splice(findIndex, 1);
  setUnAddToolbarList(nextUnAddToolbarList);
};

从已添加删除

既然我们实现添加逻辑,与之对应的就是删除逻辑,当点击“已添加模块”中的某条 Item 时,将其从“已添加模块”中删除,并同时把它添加到“未添加模块”中。如下图所示,下图是一个删除过程。

image.png

需要明确的一点是,我们在常量文件中,对于工具条数据的每一条都添加了 require 字段,意味着当 require 为 true 时,这是必选项模块,也就是无法删除。

image.png

明确必选项不能删除后,删除逻辑的就不再是难事了,小伙伴们私下自己实现删除逻辑。

抽离方法

当你把上面的添加模块、删除模块写完之后,仔细琢磨,你会发现一个规律,那就是:

  • 删除:在数组中找到目标元素,然后删掉,返回删除之后的数组
  • 添加:在数组中查找是否已添加此元素,没有添加过就追加在数组尾部,返回添加之后的数组

既然如此,作为一名有追求的程序员,我们能不能写成一个业务层面的 utils 工具方法呢?

很明显是可行的,我们在 ResumeToolbar 下新增一个 utils.ts 文件,我们来编写一下此文件

ts
// app/renderer/container/resume/ResumeToolbar/utils.ts
/**
 * @description 添加工具条模块
 * @param {TSResume.SliderItem[]} prevToolbarList 上一轮
 * @param {TSResume.SliderItem} currentToolbar 当前目标模块
 * @returns {TSResume.SliderItem[]} nextToolbarList 下一轮
 */
export const onAddToolbar = (
  prevToolbarList: TSResume.SliderItem[],
  currentToolbar: TSResume.SliderItem
): TSResume.SliderItem[] => {
  const addKeys = prevToolbarList.map((s: TSResume.SliderItem) => s.key);
  let nextToolbarList = [...Array.from(prevToolbarList)];
  if (!addKeys.includes(currentToolbar.key)) {
    nextToolbarList.push(currentToolbar);
  }
  return nextToolbarList;
};

/**
 * @description 删除工具条模块
 * @param {TSResume.SliderItem[]} prevToolbarList 上一轮
 * @param {TSResume.SliderItem} currentToolbar 当前目标模块
 * @returns {TSResume.SliderItem[]} nextToolbarList 下一轮
 */
export const onDeleteToolbar = (
  prevToolbarList: TSResume.SliderItem[],
  currentToolbar: TSResume.SliderItem
): TSResume.SliderItem[] => {
  const nextToolbarList = [...Array.from(prevToolbarList)];
  const findIndex = nextToolbarList.findIndex((s: TSResume.SliderItem) => s.key === currentToolbar.key);
  if (findIndex > -1) {
    nextToolbarList.splice(findIndex, 1);
  }
  return nextToolbarList;
};

这样的好处是:在业务逻辑上,我们不用关心它是如何删除、如何新增,只需要调用封装好的方法,就能实现新增、删除功能,至于如何实现,感兴趣的人再进入到对应文件查看具体代码。

ts
// app/renderer/container/resume/ResumeToolbar/index.tsx

// 👇 改造后的逻辑
import { onAddToolbar, onDeleteToolbar } from './utils';

// 添加模块
const onAddSliderAction = (moduleToolbar: TSResume.SliderItem) => {
  const nextAddSliderList = onAddToolbar(addToolbarList, moduleToolbar);
  setAddToolbarList(nextAddSliderList);
  const nextUnAddSliderList = onDeleteToolbar(unAddToolbarList, moduleToolbar);
  setUnAddToolbarList(nextUnAddSliderList);
};

// 删除模块
const onDeleteSliderAction = (moduleSlider: TSResume.SliderItem) => {
  const nextAddSliderList = onDeleteToolbar(addToolbarList, moduleSlider);
  setAddToolbarList(nextAddSliderList);
  const nextUnAddSliderList = onAddToolbar(unAddToolbarList, moduleSlider);
  setUnAddToolbarList(nextUnAddSliderList);
};

工具条与模版之间的通信

虽然我们实现了工具条模块功能和优化交互体验,但从始至终,一切都是我们“单相思”,我们在 ResumeToolbar 组件中做的任何操作,是不被 ResumeContent 简历内容组件感知的。一直以来都是暗恋,那么接下来,我们需要“公开表白”了,我们要让 ResumeContent 知道,我们为它做了什么。

redux 记录数据

我们期望,工具条的“已添加模块”能与简历上展示的信息同步。这里我们在 redux 中添加一个数据段,用于存储我们当前“已添加模块”的所有 key。

进入 renderer/store 中,新增一个 templateModel.ts 文件,我们来修改此文件

ts
const templateModel: TSRcReduxModel.Props<TStore> = {
  namespace: 'templateModel',
  openSeamlessImmutable: true,
  state: {
    resumeToolbarKeys: [], // 选中工具条模块的keys
  },
};

export default templateModel;

不要忘记了,需要将此 model 注入到 redux 中

ts
// app/renderer/store/index.ts

import globalModel from './globalModel';
import resumeModel from './resumeModel';
// 👇 引入刚新增的 model
import templateModel from './templateModel';

const reduxModel = new RcReduxModel([globalModel, resumeModel, templateModel]);

// ...

我们需要将“已添加模块”中的工具条 key 追加到 resumeToolbarKeys 中,前面说过了,如果想修改 redux 中的数据值,我们只需要通过发起一个 Action,且 rc-redux-model 提供了一个通用的 API,接下来我们改造之前的逻辑代码。

ts
// app/renderer/container/resume/ResumeToolbar/index.tsx
import { useDispatch } from 'react-redux';

function ResumeToolbar() {
  // 省略代码 ...
  const dispatch = useDispatch();
  useEffect(() => {
    if (RESUME_TOOLBAR_LIST.length > 0) {
      // 👇 将已添加模块的所有keys进行修改
      changeResumeToolbarKeys(_addToolbarList.map((s) => s.key));
    }
  }, []);

  // 👇 修改 redux 中的值,使用 rc-redux-model 提供的 API
  const changeResumeToolbarKeys = (moduleKeys: string[]) => {
    if (moduleKeys.length > 0) {
      dispatch({
        type: 'templateModel/setStore',
        payload: {
          key: 'resumeToolbarKeys',
          values: moduleKeys,
        },
      });
    }
  };

  // 添加模块
  const onAddSliderAction = (moduleToolbar: TSResume.SliderItem) => {
    // 省略代码 ... 👇 将已添加模块的所有keys进行修改
    changeResumeToolbarKeys(nextAddSliderList.map((s: TSResume.SliderItem) => s.key));
  };

  // 删除模块
  const onDeleteSliderAction = (moduleSlider: TSResume.SliderItem) => {
    // 省略代码 ... 👇 将已添加模块的所有keys进行修改
    changeResumeToolbarKeys(nextAddSliderList.map((s: TSResume.SliderItem) => s.key));
  };
}

我们如何验证是不是真的将其添加到 redux 了呢?通过控制面板,我们可以看到 redux 打印的数据,假设我将“工作期望”添加到“已添加模块”,那么此刻的 redux 数据应该是存在该 key 值的。

image.png

与高中谈恋爱一样,我们写好了情书(写好了 Redux 值),需要将这封情书送到她手上(让 ResumeContent 组件接收到数据),借助 useSelector API 让它帮我们传递情书。

我们进入到 /ResumeContent/UseTemplate/templateOne,修改它的入口文件 index.tsx

ts
/**
 * @desc 模板1
 * @author pengdaokuan
 */
import React from 'react';
import './index.less';
// 引入一系列的组件代码,在此省略
import { useSelector } from 'react-redux';
import { RESUME_TOOLBAR_MAPS } from '@common/constants/resume';

function TemplateOne() {
  // 👇 获取简历信息数据
  const base: TSResume.Base = useSelector((state: any) => state.resumeModel.base);
  // 👇 获取工具条模块 keys
  const resumeToolbarKeys: string[] = useSelector((state: any) => state.templateModel.resumeToolbarKeys);

  // 必须带有id,以方便导出时获取DOM元素内容
  return (
    <div styleName="a4-box">
      <div styleName="flex container" id="visPdf">
        {/* 左侧 */}
        <div styleName="left">
          <div styleName="avatar">
            <Avatar />
          </div>
          <div styleName="fillColor" />
          <div styleName="baseData">
            <BaseInfo />
            {resumeToolbarKeys.includes(RESUME_TOOLBAR_MAPS.contact) && <Contact />}
            {resumeToolbarKeys.includes(RESUME_TOOLBAR_MAPS.workPrefer) && <Job />}
            {resumeToolbarKeys.includes(RESUME_TOOLBAR_MAPS.certificate) && <Certificate />}
          </div>
        </div>
        {/* 内容 */}
        <div styleName="center">
          {(resumeToolbarKeys.includes(RESUME_TOOLBAR_MAPS.evaluation) || base?.username) && <Synopsis />}
          <div styleName="listData">
            {resumeToolbarKeys.includes(RESUME_TOOLBAR_MAPS.skill) && <Skill />}
            {resumeToolbarKeys.includes(RESUME_TOOLBAR_MAPS.schoolExperience) && <Post />}
            {resumeToolbarKeys.includes(RESUME_TOOLBAR_MAPS.projectExperience) && <Project />}
            {resumeToolbarKeys.includes(RESUME_TOOLBAR_MAPS.workExperience) && <Work />}
          </div>
        </div>
      </div>
    </div>
  );
}

export default TemplateOne;

如上所示,我们在使用简历处,根据当前“已添加模块”的工具条同步展示简历的数据,最终效果如下

image.png

发布订阅实现弹窗显示

上面我们实现了数据展示之间的同步,接下来,我们到了最为重要的交互环节,点击模块,弹窗显示。

我们期望在“已添加模块”中,当点击某条模块 Item 时,显示弹窗,弹窗内容为当前该模块对应的简历数据。让我们思考一下什么方式实现会比较优雅?

  • 方式一:数据驱动方式,在 redux 中定义一个值,暂且叫它 form_name,当点击某 Item 时,就修改该值,显示弹窗,当关闭弹窗之后,清空该值。简历内容组件监听此值,当值发生改变:
    • 值为空,表示当前弹窗关闭,不需要显示弹窗
    • 值非空,但发生改变,显示相应的表单弹窗
  • 方式二:事件驱动方式,通过发布订阅模式,当点击某 Item 时,发布事件,在简历内容组件订阅此事件,通过传参形式获取当前需要显示的表单弹窗名称,从而显示相应的弹窗。

到底采用数据驱动还是事件驱动,经过再三思考,还是采用事件驱动方式,最主要的一个原因在于,我们不用在 redux 中维护一个数据值,且这个数据值需要频繁修改。

我们如何实现一套发布订阅呢?可能小伙伴们担心:不会需要我们写一套 EventEmitter 吧?duck不必,EventTarget.dispatchEvent 支持我们向一个指定的事件目标派发一个事件(更多请移步文档),多说无益,直接亮剑吧。

在 renderer/common 文件夹下,新增一个文件夹,取名为 messager,我们来实现一下此通讯器

ts
// app/renderer/common/messager/index.ts
export const MESSAGE_EVENT_NAME_MAPS = {
  OPEN_FORM_MODAL: 'open_form_modal', // 简历模块选择
};

class Messager {
  send = (eventName: string, payload: any) => {
    document.dispatchEvent(
      new CustomEvent(eventName, {
        detail: {
          payload: payload,
        },
      })
    );
  };
  receive = (e: any, messageHandler: Function) => {
    if (messageHandler) {
      const payload = e?.detail?.payload;
      messageHandler(payload);
    }
  };
}

export default new Messager();

如上所示,我们实现了一个 send 方法和 receive 方法,同时维护了一套通信事件名称,那么在业务端该如何使用呢?

点击模块发送事件

我们回到 resume/ResumeToolbar 组件,我们为“已添加模块”的每一条Item注册 onClick 事件

ts
// app/renderer/container/resume/ResumeToolbar/index.tsx
import Messager, { MESSAGE_EVENT_NAME_MAPS } from '@common/messager';

function ResumeToolbar() {
  // 省略代码...
  return (
    // 👇 是伪代码
    {addToolbarList.map((addSlider: TSResume.SliderItem) => {
       return (
          <div styleName="box" key={addSlider.key} onClick={() => {
            // 👇 事件发送
             Messager.send(MESSAGE_EVENT_NAME_MAPS.OPEN_FORM_MODAL, {
               form_name: addSlider.key,
            });
          }}>
             <div styleName="info">
               {!addSlider.require && (
                 <div styleName="action">
                    <i styleName="delete" onClick={(e: React.MouseEvent) => {
                        // 👇 这里需要阻止冒泡!!!
                        e.stopPropagation && e.stopPropagation();
                        onDeleteSliderAction(addSlider);
                     }}/>
                  </div>
               )}
             </div>
         </div>
       );
    })}
  );
}

export default ResumeToolbar;

最为重要的就是在删除事件上,需要阻止冒泡,否则点击删除之后,事件会冒泡,导致的结果就是:我点击删除,然而删除后还给我通过通信器 Messager 发了一个事件。

简历内容组件订阅事件

我们在 ResumeContent 组件的入口文件 index.tsx 中,订阅此事件,并获取传参值: form_name

ts
// app/renderer/container/resume/ResumeContent/index.tsx
import Messager, { MESSAGE_EVENT_NAME_MAPS } from '@common/messager';

function ResumeContent() {
  // 👇 监听此事件
  useEffect(() => {
    document.addEventListener(MESSAGE_EVENT_NAME_MAPS.OPEN_FORM_MODAL, onReceive);
    return () => {
      document.removeEventListener(MESSAGE_EVENT_NAME_MAPS.OPEN_FORM_MODAL, onReceive);
    };
  }, []);
  /**
   * @description 接收订阅事件的传参
   */
  const onReceive = (e: any) => {
    Messager.receive(e, (data: any) => {
      console.log('发布订阅,传参值为: ', data);
    });
  };
  
  return (
    // ...
  )
}
export default ResumeContent;

刷新一下页面,此时我们点击一下“已添加模块”中的工具条,看看打印的数据是否正确

image.png

没有问题,这表示我们通过发布订阅实现工具条模块与简历模版的消息通信是可行的。

弹窗的显示

我们能拿到 form_name 值,意味着,我们知道当前目标模块是哪个了,接下来实现对应弹窗的现实。

这属于通用的表单弹窗组件,不管哪个简历模版,对于信息的录入都是通过弹窗交互实现的,所以这是业务通用的组件,为此,我们在 /container/resume/ResumeContent 文件夹下新增一个文件夹,取名为:UseForm,寓意着使用的表单组件。接下来我们简单实现一下弹窗显示功能。

在 UseForm 下新增个人信息表单弹窗,取名为:Personal,我们来编写一下

ts
// app/renderer/container/resume/ResumeContent/UseForm/Personal/index.tsx
import React from 'react';
import './index.less';
import MyModal from '@common/components/MyModal';
import MyInput from '@common/components/MyInput';
import { useSelector } from 'react-redux';

function Personal() {
  const hobby: string = useSelector((state: any) => state.resumeModel.hobby);
  const base: TSResume.Base = useSelector((state: any) => state.resumeModel.base);
  return (
    <MyModal.Dialog title="个人信息">
      <div styleName="form">
        <div styleName="flex">
          <div styleName="left">
            <span styleName="require">*</span> 
          </div>
          <div styleName="right">
            <MyInput onChange={(e) => {}} value={base?.username || ''} placeholder="请输入姓名" allowClear={true} />
          </div>
        </div>
      </div>
    </MyModal.Dialog>
  );
}

export default Personal;

紧接着,我们前往 ResumeContent 中,引入此组件(伪代码)

ts
import Messager, { MESSAGE_EVENT_NAME_MAPS } from '@common/messager';
import { RESUME_TOOLBAR_MAPS } from '@common/constants/resume';

// 👇 引入我们写的表单弹窗组件
import PersonalForm from './UseForm/Personal';
import EducationForm from './UseForm/Education';

function ResumeContent() {
  // 👇 定义 state 值
  const [formName, setFormName] = useState('');
  const [showFormModal, setShowFormModal] = useState(false);

  useEffect(() => {
    document.addEventListener(MESSAGE_EVENT_NAME_MAPS.OPEN_FORM_MODAL, onReceive);
    return () => {
      document.removeEventListener(MESSAGE_EVENT_NAME_MAPS.OPEN_FORM_MODAL, onReceive);
    };
  }, []);
  /**
   * @description 接收订阅事件的传参
   */
  const onReceive = (e: any) => {
    Messager.receive(e, (data: any) => {
      setShowFormModal(true);
      setFormName(data?.form_name);
    });
  };
  return (
    <MyScrollBox maxHeight={height - HEADER_ACTION_HEIGHT}>
      <UseTemplateList.TemplateOne />
      {showFormModal && (
        <>
          {formName === RESUME_TOOLBAR_MAPS.personal && <PersonalForm />}
          {formName === RESUME_TOOLBAR_MAPS.education && <EducationForm />}
          // 还有许多...
        </>
      )}
    </MyScrollBox>
  );
}
export default ResumeContent;

当我们点击“个人信息”模块时,就会显示对应的表单弹窗,如下图所示

image.png

剩下的工作就好办了,以 PersonalForm 为基准,将剩下的所有的表单弹窗组件都写好,最后在 ResumeContent 引入,根据条件匹配从而显示对应的弹窗。

完整参考阅读👉 chapter-10 之表单弹窗

总结

本章节带着大家一步步优化工具条模块的交互效果,以及对新增、删除的逻辑梳理,进而方法抽离。在数据驱动与事件驱动的对比之下,采取事件驱动实现不同组件之间的数据通信。同时封装 Messager 模块,让业务端更好的发送事件与接收事件。

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

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