vue3.js 实战–备忘录的完结

最近没有学到什么好的技能知识点分享,不过本次给我们带来一个实战,那便是运用 vue.js3 + ts 完结了一个小小的备忘录。尽管每个人的手机都自带有备忘录,但我更倾向于自己写一个备忘录,自己记笔记用,好了废话不多说,咱们开端吧。

备忘录的规划

既然是制作一个 web 版别的备忘录,那必定也是要好好规划一番的,备忘录首要包括三个页面,如下:

  1. 笔记展现页。
  2. 笔记新增/修正页。
  3. 笔记概况页。

其间展现页如下图所示:

一个小小的备忘录,让我了解了vue3全家桶

从上图咱们能够知道展现页包括标题和中心笔记内容展现以及底部展现由几个备忘录和相关操作。

新增修正页如下图所示:

一个小小的备忘录,让我了解了vue3全家桶

从上图,咱们能够看到新增修正页就仅仅改变了中心的笔记展现内容为新增/修正表单,然后底部操作按钮改变为保存按钮。

笔记概况页如下图所示:

一个小小的备忘录,让我了解了vue3全家桶

概况页更简略,其实便是简略的展现即可。

也能够在线拜访该备忘录。

尽管以上的页面看起来都比较简略,但其实包括了许多逻辑,下面让我逐个娓娓道来。

技能知识点

本次备忘录咱们用到了如下技能栈:

  1. vue3.js 根底语法以及状况办理东西 pinia 还有 vue 路由。
  2. 运用 localforage 来存储办理笔记数据(后期也能够改造成后端接口)。
  3. 封装了一个弹出框插件,以及运用到了自己写的消息提示框插件 ew-message
  4. vite 建立工程。

别的咱们约好了笔记的数据结构,如下所示:

interface NoteDataItem extends NoteFormDataItem {
  id?: string;
  createDate?: string;
  updateDate?: string;
}
interface NoteFormDataItem {
  classification?: string;
  content?: string;
  title?: string;
}

这儿能够解说一下每个特点的含义,首要是 id 特点,这个不需多说,咱们运用 uuid 来作为 id 特点值,其它如 title 则为笔记标题,content 则为笔记内容,classification 则为笔记分类,createDate 则为创立日期,updateDate 则为更新日期。

事实上 content 特点值咱们仍是有很大的改造空间的,由于咱们的笔记应该不止有文本内容,还有图片链接,等等,可是这儿咱们仅仅考虑存储文本内容。

接下来咱们来看下源码目录结构如下图所示:

一个小小的备忘录,让我了解了vue3全家桶

能够看到咱们的源码目录结构也很明晰,剖析如下:

  1. 首要是 components 目录,这儿首要放置咱们封装的备忘录用到的组件。
  2. const 目录用来界说一些常量,比方咱们这儿用到了 iconfont 的许多图标,就界说在这个目录下。
  3. 然后便是 hooks 钩子函数目录。
  4. plugins 目录代表插件,这儿首要是封装的弹出框插件。
  5. routes 目录,vue 路由目录。
  6. stores 目录,vue 状况办理数据。
  7. styles 目录,款式。
  8. utils 目录,东西函数。

能够这么说,尽管这仅仅一个小小的实战项目,但其完结已包括了 vue 项目的根本,一个大型项目的根本骨架也便是这样慢慢从零到一累计起来的,只要掌握了本实战项目,那你的 vue.js 框架现已算是娴熟运用呢。

依据实际效果,咱们能够知道,整个备忘录其实全体变化不大,首要都是一些核心的小组件进行变化,比方新增修正页面就首要改变图标还有中心内容区,再比方点击修正多选删去的时分,也仅仅为数据增加一个多选框,因而,咱们会在 app.vue 也便是根组件文件中界说一个 mainType 变量,用来确认当时是处于何种操作状况,而且咱们还要将这种状况存储到会话存储中,以避免页面改写时导致状况判别相关组件的显隐失效然后呈现一些展现问题。

初始化项目

初始化项目很简略,其实依照官方vite文档上的说明即可初始化成功,这儿不需求多说,然后咱们需求安装相关依靠。如下所示:

pnpm  ew-message localforage pinia vue-router --save-dev

安装好咱们需求的依靠之后,接下来依照咱们终究完结的源码格局,咱们删掉 app.vue 里边的一些示例代码,以及 components 目录下的 helloworld.vue 文件,新增 const,stores,routes,plugins,hooks,utils,styles 等目录。在 app.vue 同目录下新建一个 global.d.ts 用来界说备忘录笔记数据结构,如下所示:

// global.d.ts
interface NoteDataItem extends NoteFormDataItem {
  id?: string;
  createDate?: string;
  updateDate?: string;
}
interface NoteFormDataItem {
  classification?: string;
  content?: string;
  title?: string;
}

需求留意的便是这儿咱们为什么要区别出 NoteFormDataItem 和 NoteDataItem,这也是区别新增和修正表单数据与终究展现的数据的结构,新增/修正表单数据咱们只需求核心的三个数据即可。

ps: 当然,这儿其实咱们还能够规划一个状况字段,用于判别该备忘录是否现已完结,不过这属于后续扩展,这儿暂时不解说。

接下来,咱们先来界说路由并挂载到 vue 根实例上,在 routes 目录下新建一个 route.ts,里边写上如下代码:

import { createRouter, createWebHashHistory } from 'vue-router';
const routes = [
  {
    path: '/',
    name: 'index',
    component: () => import('../components/List/List.vue')
  },
  {
    path: '/detail/:uuid',
    name: 'detail',
    component: () => import('../components/Detail/Detail.vue')
  },
  {
    path: '/*',
    name: 'error',
    component: () => import('../components/Error/Error.vue')
  }
];
const router = createRouter({
  history: createWebHashHistory(),
  routes
});
router.beforeEach((to, _, next) => {
  if (to.matched.length === 0) {
    // 没有匹配到路由则跳转到404页面
    next({ name: 'error' });
  } else {
    next(); // 正常跳转到相应路由
  }
});
export default router;

这儿能够解释一下,咱们运用 createRouter 办法创立一个路由,这儿选用的是 hash 形式而非 history 形式,同理咱们界说了一个 routs 路由数组,路由数组包括了是哪个路由装备目标,分别是 path 界说路由途径,name 界说路由名字,component 用于烘托相关组件,这儿包括了 3 个组件,列表组件 List,概况组件 Detail 和错误组件 Error,写上这三个组件导入的代码的一起,咱们能够在 components 目录下新建这三个组件,然后咱们写了一个路由导航护卫办法,在办法里依据 matched.length 特点然后判别是否匹配到相关路由,假如没有匹配到,就跳转到 404 页面,不然即正常跳转,这儿的 next 办法是 beforeEach 暴露出来的参数,详细用法能够参阅vue-router 官方文档

在 main.ts 中,咱们改造一下代码,如下所示:

import { createApp } from 'vue';
import { createPinia } from 'pinia';
import './styles/variable.css';
import './styles/common.css';
import App from './App.vue';
import router from './routes/route';
const pinia = createPinia();
const app = createApp(App);
app.use(router);
app.use(pinia).mount('#app');

能够看到,咱们运用 app.use 办法将 pinia 和 router 都挂载到了根实例上,然后咱们导入了两个款式文件 common.css 和 variable.css,这两个文件咱们将创立在 styles 目录下。

由于备忘录全体款式比较简略,根本上没有什么能够多讲的知识点,仅有需求说明的便是这儿的输入框元素,咱们是经过 div 元素模仿的,而且咱们经过 attr 特点成功将元素的 placeholder 特点的内容值烘托到标签元素中。css 款式代码如下所示:

.ew-note-textarea:focus,
.ew-note-textarea:focus::before,
.ew-note-textarea:not(:empty):before,
.ew-note-input:focus,
.ew-note-input:focus::before,
.ew-note-input:not(:empty):before {
  content: '';
}
.ew-note-textarea::before,
.ew-note-input::before {
  content: attr(placeholder);
  display: block;
  color: var(--mainTextareaPlaceholderColor--);
  letter-spacing: 2px;
}

其它都是一些根底款式没什么好说的,感兴趣的能够参阅源码

接下来,咱们需求创立 2 个状况,第一个便是新增备忘录时用到的表单数据,而第二个则是咱们选中数据时存储的 id 数组,这儿由于组件嵌套太深,因而咱们运用状况办理东西来办理表单数据和选中数据存储的 ID 数组(这个数据首要用来批量删去备忘录数据的)。在 stores 目录下新建 checkedStore.ts 和 noteStore.ts,里边的代码分别如下所示:

// checkedStore.ts
import { defineStore } from 'pinia';
export const useCheckedStore = defineStore('noteCheckedData', {
  state: () => {
    return {
      checkedData: [] as string[]
    };
  }
});
// noteStore.ts
import { defineStore } from 'pinia';
import { updateFormKeys } from '../const/keys';
export const useNoteStore = defineStore('noteFormData', {
  state: () => {
    return {
      title: '',
      classification: '',
      content: ''
    };
  },
  actions: {
    clear() {
      updateFormKeys.forEach(key => {
        this[key as keyof NoteFormDataItem] = '';
      });
    }
  }
});

能够看到,咱们运用 pinia 供给的 defineStore 办法界说 2 个状况,这个办法接受 2 个参数,第一个是数据 key,第二个则是数据装备目标,装备目标中能够装备 state 以及 actions,state 即状况,actions 即行为。这儿咱们还仅仅简略的运用 pinia 来界说状况,由于咱们这样界说就足够了。值得留意的便是第二个 store 里边咱们界说了一个 clear 办法,顾名思义便是清空数据状况值,这儿引入了一个 updateFormKeys 特点数组。它在 const 目录下的 keys.ts 中界说,代码如下所示:

// keys.ts
export const updateFormKeys = ['title', 'classification', 'content'];

到这儿为止,咱们的根底项目核心就建立好了,接下来,让咱们一步一步对每个模块的代码进行剖析。

东西函数模块

东西函数用到的也不多,首要分为以下几类:

  1. 数据类型的判别。
  2. 创立 uuid。
  3. 回到顶部东西函数。
  4. 时刻日期格局化。
  5. 操作类名东西函数。

接下来,咱们就依照以上五个类别来逐个剖析每一个东西函数。

数据类型的判别

首要是数据类型的判别,这儿咱们首要用到了是否是字符串,是否是布尔值以及是否是目标的数据类型,这儿咱们运用 typeof 操作符来判别数据类型,如下所示:

export const isString = <T>(value: T) => typeof value === 'string';
export const isBoolean = <T>(v: T) => typeof v === 'boolean';
export const isObject = <T>(v: T) => v && typeof v === 'object';

除此之外,还有一个判别是否是空目标的东西函数,很简略,首要判别是否是目标,然后运用 Object.keys 办法获取目标的特点,收集成为一个数组,然后判别数组的长度是否为 0 即可判别是否是空目标,代码如下所示:

export const isEmptyObject = <T>(v: T) =>
  isObject(v) && Object.keys(v as object).length === 0;

创立 uuid 东西函数

创立 uuid,咱们运用 Math.random 函数取随机数,然后乘以一个几万或许几十万的数值,然后去截取,再与当时创立日期拼接起来,再拼接一个随机数,每一次拼接运用-来拼接起来,即可得到终究的 uuid,这样也能保证每次创立出来的 uuid 是仅有的。代码如下所示:

export const createUUID = () =>
  (Math.random() * 10000000).toString(16).substring(0, 4) +
  '-' +
  new Date().getTime() +
  '-' +
  Math.random().toString().substring(2, 5);

回到顶部东西函数

要完结回到顶部的逻辑,那么就需求监听工作,因而咱们首要需求封装一个 on 办法,运用 element.addEventListener 来监听一个工作。代码如下所示:

export const on = (
  element: HTMLElement | Document | Element | Window,
  type: string,
  handler: EventListenerOrEventListenerObject,
  useCapture = false
) => {
  if (element && type && handler) {
    element.addEventListener(type, handler, useCapture);
  }
};

然后完结回到顶部的逻辑便是分两步,第一步便是页面翻滚超出可见区域高度的时分,就呈现回到顶部按钮不然就躲藏的逻辑,第二步则是点击回到顶部按钮修正翻滚值为 0,这儿选用定时器的办法使得翻滚值是缓缓变成 0 的。依据这个思路,咱们能够写出如下代码:

export const toTop = (top: HTMLElement, scrollEl?: HTMLElement) => {
  let scrollElement = scrollEl
      ? scrollEl
      : document.documentElement || document.body,
    timer: ReturnType<typeof setTimeout> | null = null,
    backTop = true;
  const onScrollHandler = () => {
    const oTop = scrollElement.scrollTop;
    // 可能有10px的误差
    const clientHeight = Math.max(
      scrollElement?.scrollHeight - scrollElement.offsetHeight - 10,
      0
    );
    if (oTop > clientHeight) {
      top.style.visibility = 'visible';
    } else {
      top.style.visibility = 'hidden';
    }
    if (!backTop && timer) {
      clearTimeout(timer);
    }
    backTop = true;
  };
  const toTopHandler = () => {
    const oTop = scrollElement.scrollTop,
      speed = Math.floor(-oTop / 6);
    scrollElement.scrollTop = oTop + speed;
    if (oTop === 0) {
      timer && clearTimeout(timer);
      top.style.visibility = 'hidden';
      backTop = false;
    } else {
      timer = setTimeout(toTopHandler, 30);
    }
  };
  on(top, 'click', toTopHandler);
  on(scrollElement || window, 'scroll', onScrollHandler);
};

以上之所以创立一个 backTop 变量,是为了保证两个逻辑之间不起冲突。这个办法支持传入 2 个参数,第一个参数为回到顶部按钮元素,第二个参数则为翻滚元素(也便是呈现翻滚条的元素)。这儿由于咱们完结的弹窗插件也用到了一些东西函数,会和这儿重复,因而咱们单独提取出来封装成了一个类,如下所示:

// baseUtils.ts
export default class ewWebsiteBaseUtils {
  eventType: string[];
  constructor() {
    this.eventType = this.isMobile()
      ? ['touchstart', 'touchmove', 'touchend']
      : ['mousedown', 'mousemove', 'mouseup'];
  }
  isMobile() {
    return !!navigator.userAgent.match(/(iPhone|iPod|Android|ios)/i);
  }
  $(selector: string, el: Document | HTMLElement = document) {
    return el.querySelector(selector);
  }
  $$(selector: string, el: Document | HTMLElement = document) {
    return el.querySelectorAll(selector);
  }
  getStyle(
    el: HTMLElement,
    selector: string | null | undefined = null,
    prop: string
  ) {
    const getComputedStyle = window.getComputedStyle || document.defaultView;
    return getComputedStyle(el, selector).getPropertyValue(prop);
  }
  hasClass(el: HTMLElement, className: string) {
    if (el.classList.contains) {
      return el.classList.contains(className);
    } else {
      const matchRegExp = new RegExp('(^|\s)' + className + '(\s|$)');
      return matchRegExp.test(el.className);
    }
  }
  handleClassName(className?: string, status?: boolean) {
    const condition = this.isBoolean(status)
      ? status
      : this.isString(className) && className;
    return condition ? ` ${className}` : '';
  }
  handleTemplate(isRender?: boolean, template?: string) {
    return this.isBoolean(isRender) &&
      isRender &&
      this.isString(template) &&
      template
      ? template
      : '';
  }
  isObject<T>(v: T) {
    return v && typeof v === 'object';
  }
  isString<T>(value: T) {
    return typeof value === 'string';
  }
  isBoolean<T>(v: T) {
    return typeof v === 'boolean';
  }
  on(
    element: HTMLElement | Document | Element | Window,
    type: string,
    handler: EventListenerOrEventListenerObject,
    useCapture = false
  ) {
    if (element && type && handler) {
      element.addEventListener(type, handler, useCapture);
    }
  }
  off(
    element: HTMLElement | Document | Element | Window,
    type: string,
    handler: EventListenerOrEventListenerObject,
    useCapture = false
  ) {
    if (element && type && handler) {
      element.removeEventListener(type, handler, useCapture);
    }
  }
  create(tagName: string) {
    return document.createElement(tagName);
  }
  createElement(str: string) {
    const element = this.create('div');
    element.innerHTML = str;
    return element.firstElementChild;
  }
  assign(target: Record<string, any>, ...args: Record<string, any>[]) {
    if (Object.assign) {
      return Object.assign(target, ...args);
    } else {
      if (target === null) {
        return;
      }
      const _ = Object(target);
      args.forEach(item => {
        if (this.isObject(item)) {
          for (let key in item) {
            if (Object.prototype.hasOwnProperty.call(item, key)) {
              _[key] = item[key];
            }
          }
        }
      });
      return _;
    }
  }
  addClass(el: HTMLElement, className: string) {
    return el.classList.add(className);
  }
  removeClass(el: HTMLElement, className: string) {
    return el.classList.remove(className);
  }
}

时刻日期格局化

接下来咱们便是封装一下时刻日期的格局化,其实很简略,便是经过 Date 目标获取到年月日时分秒,然后改下格局即可,代码如下所示:

export const formatNumber = (n: number | string) => {
  n = n.toString();
  return n[1] ? n : '0' + n;
};
export const formatTime = (date: Date = new Date()) => {
  const year = date.getFullYear();
  const month = date.getMonth() + 1;
  const day = date.getDate();
  const hour = date.getHours();
  const minute = date.getMinutes();
  const second = date.getSeconds();
  return (
    [year, month, day].map(formatNumber).join('-') +
    ' ' +
    [hour, minute, second].map(formatNumber).join(':')
  );
};

这儿有一个有意思的点,便是 formatNumber 函数傍边怎么确认是需求补零的呢?首要年份咱们是不需求补零的,至于其它时刻只要小于 10 的状况下才会补零,因而咱们转成字符串,只需求判别假如第二个字符存在,代表大于 10 了,就不需求补零,不然才补零。

操作类名东西函数

操作类名函数,这个我首要用在了 svg 元素上,观察 const/icon.ts 中,我的图标是如此界说的,如下所示:

import { handleClassName } from '../utils/utils';
export const cancelIcon = (className?: string) =>
  `<svg t="1701689019983" class="cancel-icon${handleClassName(
    className
  )}" viewBox="0 0 1140 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9471" ><path d="M474.133828 76.681372c-261.931418 0-474.133828 212.297312-474.133828 474.133828 0 261.836515 212.20241 474.133828 474.133828 474.133828s474.133828-212.297312 474.133828-474.133828C948.267655 288.978684 735.970343 76.681372 474.133828 76.681372zM521.774977 637.271548 521.774977 521.774977c-57.321223 0-203.471362 1.328638-203.471362 158.487488 0 82.28063 55.80278 150.990176 130.016682 166.838925C329.217424 830.208712 237.066914 724.487118 237.066914 595.134754c0-240.293605 245.228545-242.286562 284.708063-242.286562L521.774977 254.529008l189.330862 192.08304L521.774977 637.271548z" p-id="9472"></path></svg>`;
export const emptyDataIcon = (className?: string) =>
  `<svg t="1690278699020" class="empty-data-icon${handleClassName(
    className
  )}" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3148"><path d="M102.4 896a409.6 51.2 0 1 0 819.2 0 409.6 51.2 0 1 0-819.2 0Z" opacity=".1" p-id="3149"></path><path d="M116.736 376.832c0 8.704 6.656 15.36 15.36 15.36s15.36-6.656 15.36-15.36-6.656-15.36-15.36-15.36c-8.192 0-15.36 7.168-15.36 15.36zM926.72 832c-19.456 5.12-23.552 9.216-28.16 28.16-5.12-19.456-9.216-23.552-28.16-28.16 18.944-5.12 23.552-9.216 28.16-28.16 4.608 18.944 8.704 23.552 28.16 28.16zM202.24 323.072c-25.088 6.656-30.208 11.776-36.864 36.864-6.656-25.088-11.776-30.208-36.864-36.864 25.088-6.656 30.208-12.288 36.864-36.864 6.144 25.088 11.776 30.208 36.864 36.864zM816.64 235.008c-15.36 4.096-18.432 7.168-22.528 22.528-4.096-15.36-7.168-18.432-22.528-22.528 15.36-4.096 18.432-7.168 22.528-22.528 3.584 15.36 7.168 18.432 22.528 22.528zM882.688 156.16c-39.936 10.24-48.128 18.944-58.88 58.88-10.24-39.936-18.944-48.128-58.88-58.88 39.936-10.24 48.128-18.944 58.88-58.88 10.24 39.424 18.944 48.128 58.88 58.88z" opacity=".5" p-id="3150"></path><path d="M419.84 713.216v4.096l33.792 31.232 129.536-62.976L465.92 760.832v36.864l18.944-18.432v-0.512 0.512l18.944 18.432 100.352-122.88v-4.096z" opacity=".2" p-id="3151"></path><path d="M860.16 551.936v-1.024c0-1.024-0.512-1.536-0.512-2.56v-0.512l-110.08-287.232c-15.872-48.64-60.928-81.408-112.128-81.408H387.072c-51.2 0-96.256 32.768-112.128 81.408L164.864 547.84v0.512c-0.512 1.024-0.512 1.536-0.512 2.56V757.76c0 65.024 52.736 117.76 117.76 117.76h460.8c65.024 0 117.76-52.736 117.76-117.76v-204.8c-0.512-0.512-0.512-0.512-0.512-1.024zM303.616 271.36s0-0.512 0.512-0.512C315.392 233.984 349.184 209.92 387.072 209.92h249.856c37.888 0 71.68 24.064 83.456 60.416 0 0 0 0.512 0.512 0.512l101.888 266.24H588.8c-8.704 0-15.36 6.656-15.36 15.36 0 33.792-27.648 61.44-61.44 61.44s-61.44-27.648-61.44-61.44c0-8.704-6.656-15.36-15.36-15.36H201.728L303.616 271.36zM829.44 757.76c0 48.128-38.912 87.04-87.04 87.04H281.6c-48.128 0-87.04-38.912-87.04-87.04v-189.44h226.816c7.168 43.52 45.056 76.8 90.624 76.8s83.456-33.28 90.624-76.8H829.44v189.44z" opacity=".5" p-id="3152"></path><path d="M512 578.56c-14.336 0-25.6-11.264-25.6-25.6V501.76H253.44l83.968-219.136 0.512-1.024c7.168-21.504 26.624-35.84 49.152-35.84h249.856c22.528 0 41.984 14.336 49.152 35.84l0.512 1.024L770.56 501.76H537.6v51.2c0 14.336-11.264 25.6-25.6 25.6z" opacity=".2" p-id="3153"></path></svg>`;
export const addIcon = (className?: string) =>
  `<svg t="1697700092492" class="add-icon${handleClassName(
    className
  )}" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="900" ><path d="M560.064 149.952a48 48 0 0 0-96 0V464H150.016a48 48 0 0 0 0 96H464v313.984a48 48 0 0 0 96 0V560h314.048a48 48 0 0 0 0-96H560V149.952z" p-id="901"></path></svg>`;
export const closeIcon = (className?: string) =>
  `<svg t="1690189203554" class="close-icon${handleClassName(
    className
  )}" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2272"><path d="M504.224 470.288l207.84-207.84a16 16 0 0 1 22.608 0l11.328 11.328a16 16 0 0 1 0 22.624l-207.84 207.824 207.84 207.84a16 16 0 0 1 0 22.608l-11.328 11.328a16 16 0 0 1-22.624 0l-207.824-207.84-207.84 207.84a16 16 0 0 1-22.608 0l-11.328-11.328a16 16 0 0 1 0-22.624l207.84-207.824-207.84-207.84a16 16 0 0 1 0-22.608l11.328-11.328a16 16 0 0 1 22.624 0l207.824 207.84z" p-id="2273"></path></svg>`;
export const checkedIcon = (className?: string) =>
  `<svg t="1702382629512" class="checked-icon${handleClassName(
    className
  )}" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2321" ><path d="M969.6 208c-9.6-9.6-25.6-9.6-35.2 0l-508.8 537.6c-19.2 19.2-48 19.2-70.4 3.2l-265.6-252.8c-9.6-9.6-25.6-9.6-35.2 0-9.6 9.6-9.6 25.6 0 35.2l265.6 252.8c38.4 38.4 102.4 35.2 137.6-3.2l508.8-537.6C979.2 233.6 979.2 217.6 969.6 208z" p-id="2322"></path></svg>`;
export const editIcon = (className?: string) =>
  `<svg t="1702451742331" class="edit-icon${handleClassName(
    className
  )}" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3330"><path d="M862.709333 116.042667a32 32 0 1 1 45.248 45.248L455.445333 613.813333a32 32 0 1 1-45.258666-45.258666L862.709333 116.053333zM853.333333 448a32 32 0 0 1 64 0v352c0 64.8-52.533333 117.333333-117.333333 117.333333H224c-64.8 0-117.333333-52.533333-117.333333-117.333333V224c0-64.8 52.533333-117.333333 117.333333-117.333333h341.333333a32 32 0 0 1 0 64H224a53.333333 53.333333 0 0 0-53.333333 53.333333v576a53.333333 53.333333 0 0 0 53.333333 53.333333h576a53.333333 53.333333 0 0 0 53.333333-53.333333V448z" p-id="3331"></path></svg>`;
export const deleteIcon = (className?: string) =>
  `<svg t="1702452402229" class="delete-icon${handleClassName(
    className
  )}" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4351" ><path d="M96 128h832v64H96zM128 256h768l-89.024 704H217.024z" p-id="4352"></path><path d="M384 64h256v96h-256z" p-id="4353"></path></svg>`;
export const backIcon = (className?: string) =>
  `<svg t="1702455221301" class="back-icon${handleClassName(
    className
  )}" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5323" ><path d="M624.788992 204.047974 585.205965 164.464026 219.560038 530.185011 585.205965 895.864013 624.788992 856.280986 298.663014 530.16105Z" p-id="5324"></path></svg>`;
export const arrowRightIcon = (className?: string) =>
  `<svg t="1702456062203" class="arrow-right-icon${handleClassName(
    className
  )}" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5477"><path d="M289.301454 938.361551c8.958022 8.93551 24.607444 7.868201 34.877345-2.312672l405.886217-403.662573c5.846148-5.780657 8.581446-13.271258 8.314363-20.306488 0.331551-7.080256-2.423189-14.637372-8.270361-20.463054L324.178799 87.966471c-10.269901-10.225899-25.875321-11.248182-34.877345-2.322905-8.960069 8.946766-7.936763 24.451902 2.334161 34.666544l393.880789 391.68068L291.635615 903.68375C281.364691 913.908626 280.341385 929.423995 289.301454 938.361551z" p-id="5478"></path></svg>`;
export const saveIcon = (className?: string) =>
  `<svg t="1702465877637" class="save-icon${handleClassName(
    className
  )}" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6475"><path d="M814.805 128a51.179 51.179 0 0 1 51.179 51.179V844.01a51.179 51.179 0 0 1-51.179 51.157H201.173a51.179 51.179 0 0 1-51.178-51.157V179.179A51.179 51.179 0 0 1 201.173 128h613.654zM329.024 434.837a51.093 51.093 0 0 1-51.179-51.093V179.157h-76.672v664.854h613.76V179.179H738.22v204.48a51.179 51.179 0 0 1-51.179 51.178H329.024z m0-51.093h357.995V179.157H329.024v204.587z m357.91 204.501a25.557 25.557 0 1 1 0.085 51.072H329.024a25.536 25.536 0 1 1 0-51.072h357.91z" p-id="6476"></path></svg>`;
export const errorIcon = (className?: string) =>
  `<svg t="1702887842356" class="error-icon${handleClassName(
    className
  )}" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2350"><path d="M931.6 585.6v79c28.6-60.2 44.8-127.4 44.8-198.4C976.4 211 769.4 4 514.2 4S52 211 52 466.2c0 3.2 0.2 6.4 0.2 9.6l166-206h96.4L171.8 485.6h46.4v-54.8l99.2-154.6V668h-99.2v-82.4H67.6c43 161 170.6 287.4 332.4 328.6-10.4 26.2-40.6 89.4-90.8 100.6-62.2 14 168.8 3.4 333.6-104.6C769.4 873.6 873.6 784.4 930.2 668h-97.6v-82.4H666.4V476l166.2-206.2h94L786.2 485.6h46.4v-59l99.2-154v313zM366.2 608c-4.8-11.2-7.2-23.2-7.2-36V357.6c0-12.8 2.4-24.8 7.2-36 4.8-11.2 11.4-21 19.6-29.2 8.2-8.2 18-14.8 29.2-19.6 11.2-4.8 23.2-7.2 36-7.2h81.6c12.8 0 24.8 2.4 36 7.2 11 4.8 20.6 11.2 28.8 19.2l-88.6 129.4v-23c0-4.8-1.6-8.8-4.8-12-3.2-3.2-7.2-4.8-12-4.8s-8.8 1.6-12 4.8c-3.2 3.2-4.8 7.2-4.8 12v72L372.6 620c-2.4-3.8-4.6-7.8-6.4-12z m258.2-36c0 12.8-2.4 24.8-7.2 36-4.8 11.2-11.4 21-19.6 29.2-8.2 8.2-18 14.8-29.2 19.6-11.2 4.8-23.2 7.2-36 7.2h-81.6c-12.8 0-24.8-2.4-36-7.2-11.2-4.8-21-11.4-29.2-19.6-3.6-3.6-7-7.8-10-12l99.2-144.6v50.6c0 4.8 1.6 8.8 4.8 12 3.2 3.2 7.2 4.8 12 4.8s8.8-1.6 12-4.8c3.2-3.2 4.8-7.2 4.8-12v-99.6L601 296.4c6.6 7.4 12 15.8 16 25.2 4.8 11.2 7.2 23.2 7.2 36V572z"  p-id="2351"></path></svg>`;
export const searchIcon = (className?: string) =>
  `<svg t="1702966824556" class="search-icon${handleClassName(
    className
  )}" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3880"><path d="M624 293.92h114.96v461.04a48 48 0 0 1-48 48H244.36a48 48 0 0 1-48-48v-576a48 48 0 0 1 48-48h332v114.96a48 48 0 0 0 47.64 48z" fill="#CBECF9" p-id="3881"></path><path d="M624 293.92h114.96v410.76a48 48 0 0 1-48 48H244.36a48 48 0 0 1-48-48V178.96a48 48 0 0 1 48-48h332v114.96a48 48 0 0 0 47.64 48z" fill="#FFFFFF" p-id="3882"></path><path d="M651.04 316.88m0 28.16l0 0.04q0 28.16-28.16 28.16l-310.24 0q-28.16 0-28.16-28.16l0-0.04q0-28.16 28.16-28.16l310.24 0q28.16 0 28.16 28.16Z" fill="#90FC95" p-id="3883"></path><path d="M526.52 398.16m0 28.16l0 0.04q0 28.16-28.16 28.16l-185.72 0q-28.16 0-28.16-28.16l0-0.04q0-28.16 28.16-28.16l185.72 0q28.16 0 28.16 28.16Z" fill="#90FC95" p-id="3884"></path><path d="M480.04 479.44m0 28.16l0 0.04q0 28.16-28.16 28.16l-139.24 0q-28.16 0-28.16-28.16l0-0.04q0-28.16 28.16-28.16l139.24 0q28.16 0 28.16 28.16Z" fill="#90FC95" p-id="3885"></path><path d="M615.16 560.72m0 28.16l0 0.04q0 28.16-28.16 28.16l-274.36 0q-28.16 0-28.16-28.16l0-0.04q0-28.16 28.16-28.16l274.36 0q28.16 0 28.16 28.16Z" fill="#90FC95" p-id="3886"></path><path d="M739.16 325.6H624a48 48 0 0 1-48-48V162.64l162.96 131.28z" fill="#CBECF9" p-id="3887"></path><path d="M691.16 810.96H244.36a56 56 0 0 1-56-56v-576a56 56 0 0 1 56-56h332a8 8 0 0 1 8 8v114.96a40 40 0 0 0 40 40h114.96a8 8 0 0 1 8 8v461.04a56 56 0 0 1-56.16 56z m-446.8-672a40 40 0 0 0-40 40v576a40 40 0 0 0 40 40h446.8a40 40 0 0 0 40-40V301.92H624a56 56 0 0 1-56-56V138.96z" fill="#2FB1EA" p-id="3888"></path><path d="M739.16 293.92H624a48 48 0 0 1-48-48V130.96z" fill="#E5F5FC" p-id="3889"></path><path d="M739.16 301.92H624a56 56 0 0 1-56-56V130.96a8 8 0 0 1 13.64-5.64l163.16 162.96a8 8 0 0 1-5.64 13.64zM584 150.28v95.64a40 40 0 0 0 40 40h96zM794.68 894L628.72 728a24 24 0 0 1 33.96-33.96L828.64 860a24 24 0 0 1-33.96 33.96z" fill="#2FB1EA" p-id="3890"></path><path d="M689.92 721.36l-27.28-27.28a24 24 0 0 0-33.96 33.96l27.28 27.28a209.76 209.76 0 0 0 33.96-33.96z" fill="#1A96E2" p-id="3891"></path><path d="M526.96 592.32m-168 0a168 168 0 1 0 336 0 168 168 0 1 0-336 0Z" fill="#FFC444" p-id="3892"></path><path d="M526.96 579.08m-154.76 0a154.76 154.76 0 1 0 309.52 0 154.76 154.76 0 1 0-309.52 0Z" fill="#FFE76E" p-id="3893"></path><path d="M526.96 768.32a176 176 0 1 1 176-176 176 176 0 0 1-176 176z m0-336a160 160 0 1 0 160 160 160 160 0 0 0-160-160z" fill="#2FB1EA" p-id="3894"></path><path d="M526.96 582m-131.48 0a131.48 131.48 0 1 0 262.96 0 131.48 131.48 0 1 0-262.96 0Z" fill="#FFC444" p-id="3895"></path><path d="M526.96 592.32m-121.16 0a121.16 121.16 0 1 0 242.32 0 121.16 121.16 0 1 0-242.32 0Z" fill="#FFFFFF" p-id="3896"></path><path d="M484.2 509.4a37.56 37.56 0 0 0-10.4-25.96 121.56 121.56 0 0 0-59.24 63.72h32a37.72 37.72 0 0 0 37.64-37.76zM648 586.64a37.52 37.52 0 0 0-20.56-6.12h-221.08c-0.36 4-0.56 8-0.56 11.8A120.56 120.56 0 0 0 424 656H630.2a120.56 120.56 0 0 0 18-63.56c-0.2-2.04-0.2-3.92-0.2-5.8z" fill="#90FC95" p-id="3897"></path><path d="M526.96 721.48A129.16 129.16 0 1 1 656 592.32a129.28 129.28 0 0 1-129.04 129.16z m0-242.32A113.16 113.16 0 1 0 640 592.32a113.28 113.28 0 0 0-113.04-113.16z" fill="#2FB1EA" p-id="3898"></path><path d="M776 176m-20 0a20 20 0 1 0 40 0 20 20 0 1 0-40 0Z" fill="#D4FFD4" p-id="3899"></path><path d="M156 568m-16 0a16 16 0 1 0 32 0 16 16 0 1 0-32 0Z" fill="#D4FFD4" p-id="3900"></path><path d="M132 188m-12 0a12 12 0 1 0 24 0 12 12 0 1 0-24 0Z" fill="#D4FFD4" p-id="3901"></path><path d="M808 428m-8 0a8 8 0 1 0 16 0 8 8 0 1 0-16 0Z" fill="#D4FFD4" p-id="3902"></path><path d="M916 908m-4 0a4 4 0 1 0 8 0 4 4 0 1 0-8 0Z" fill="#D4FFD4" p-id="3903"></path><path d="M860 996m-20 0a20 20 0 1 0 40 0 20 20 0 1 0-40 0Z" fill="#FFBDBD" p-id="3904"></path><path d="M828 716m-16 0a16 16 0 1 0 32 0 16 16 0 1 0-32 0Z" fill="#FFBDBD" p-id="3905"></path><path d="M272 948m-12 0a12 12 0 1 0 24 0 12 12 0 1 0-24 0Z" fill="#FFBDBD" p-id="3906"></path><path d="M824 72m-8 0a8 8 0 1 0 16 0 8 8 0 1 0-16 0Z" fill="#FFBDBD" p-id="3907"></path><path d="M440 76m-4 0a4 4 0 1 0 8 0 4 4 0 1 0-8 0Z" fill="#FFBDBD" p-id="3908"></path><path d="M112 420m-20 0a20 20 0 1 0 40 0 20 20 0 1 0-40 0Z" fill="#BBF1FF" p-id="3909"></path><path d="M472 976m-16 0a16 16 0 1 0 32 0 16 16 0 1 0-32 0Z" fill="#BBF1FF" p-id="3910"></path><path d="M860 500m-12 0a12 12 0 1 0 24 0 12 12 0 1 0-24 0Z" fill="#BBF1FF" p-id="3911"></path><path d="M800 320m-8 0a8 8 0 1 0 16 0 8 8 0 1 0-16 0Z" fill="#BBF1FF" p-id="3912"></path><path d="M124 852m-4 0a4 4 0 1 0 8 0 4 4 0 1 0-8 0Z" fill="#BBF1FF" p-id="3913"></path><path d="M228 28m-20 0a20 20 0 1 0 40 0 20 20 0 1 0-40 0Z" fill="#FFF4C5" p-id="3914"></path><path d="M680 84m-16 0a16 16 0 1 0 32 0 16 16 0 1 0-32 0Z" fill="#FFF4C5" p-id="3915"></path><path d="M132 704m-12 0a12 12 0 1 0 24 0 12 12 0 1 0-24 0Z" fill="#FFF4C5" p-id="3916"></path><path d="M176 320m-8 0a8 8 0 1 0 16 0 8 8 0 1 0-16 0Z" fill="#FFF4C5" p-id="3917"></path><path d="M928 632m-4 0a4 4 0 1 0 8 0 4 4 0 1 0-8 0Z" fill="#FFF4C5" p-id="3918"></path></svg>`;

这也就促进了这个东西函数的诞生,那便是假如传入了类名,则需求空格区别增加进去,不然就回来空字符串,不增加即可,至于为什么要有第二个参数,那是由于在弹出框插件傍边,假如是动态插入模板字符串,需求修正类名,那么就可能需求第二个布尔值。

经过以上剖析,咱们也就了解了这个东西函数的界说,如下所示:

export const handleClassName = (
  className?: string,
  status?: boolean
): string => {
  const condition = this.isBoolean(status)
    ? status
    : this.isString(className) && className;
  return condition ? ` ${className}` : '';
};

插件目录

弹出框插件的完结,不计划细讲,感兴趣的能够检查源码

hooks 目录

hooks 首要封装了 2 个函数,第一个便是存储数据,第二个则是获取存储数据,代码如下所示:

import localforage from 'localforage';
export const useMemoData = async () => {
  let memoStoreCacheData: string =
    (await localforage.getItem<string>('memoData')) || '';
  let memoStoreData: Array<NoteDataItem> = [];
  try {
    memoStoreData = JSON.parse(memoStoreCacheData);
  } catch (error) {
    memoStoreData = [];
  }
  return memoStoreData;
};
export const useSetMemoData = async (
  data: Array<NoteDataItem>,
  isGetCache = true
) => {
  let memoStoreCacheData = isGetCache ? await useMemoData() : [];
  let memoStoreData: Array<NoteDataItem> = [...memoStoreCacheData, ...data];
  localforage.setItem('memoData', JSON.stringify(memoStoreData));
};

这两个 hooks 咱们将在组件傍边常常用到,也便是新增,修正,删去备忘录的时分都会用到,这两个 hooks 函数的逻辑也好了解,第一个便是经过 getItem 办法获取到字符串数据然后经过 JSON.parse 解析成数组,而第二个则是实用 JSON.stringify 办法将数组转成字符串然后调用 setItem 存储。

接下来,便是各个组件的完结了。

组件模块

组件模块的一切代码都是很相似的,知识点也根本都是工作传递,单向数据流,监听数据等等。因而咱们只需求经过剖析一个根组件的源代码,根本就能够依照相同办法去了解其它组件。

根组件 app.vue

在根组件傍边,咱们能够看到,咱们将页面拆分红了 3 个部分,即头部 header,中心内容 main 以及底部 footer。如下所示:

<!-- template部分 -->
<async-header
  :mainType="mainType"
  @on-back="onBackHandler"
  :memoData="memoData"
  @on-header-click="onHeaderClickHandler"
></async-header>
<async-main
  :mainType="mainType"
  :memo-data="memoData"
  @on-delete="getMemoData"
  @on-detail="onDetailHandler"
  @on-edit="onEditHandler"
  :editData="editData"
  :showCheckBox="showCheckBox"
  @on-search="onSearchHandler"
></async-main>
<async-footer
  @on-footer-click="onFooterClick"
  :mainType="mainType"
  v-if="mainType !== 'detail'"
  :totalNote="memoData.length"
  :editData="editData"
></async-footer>

其间 mainType 便是咱们前面讲过的用来确认当时页面属于哪一模块,详细的值,咱们界说有 add,save,detail,然后 memoData 便是咱们的数据,editData 则是咱们的修正数据,它应该是一个目标,totalNote 便是总共有多少条备忘录,showCheckBox 表明是否显示多选框然后触发多选删去操作。其它便是一些工作,比方 on-back 便是点击回来按钮所执行的逻辑,on-delete 便是点击单个备忘录删去的逻辑,on-detail 点击跳转到备忘录概况的逻辑,on-edit 表明点击修正单个备忘录的的逻辑,on-search 则是点击查找的逻辑。

然后能够看下咱们的 ts 逻辑,代码如下所示:

<script setup lang="ts">
import { defineAsyncComponent, ref, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useMemoData, useSetMemoData } from './hooks/useMemoData';
import { useCheckedStore } from './stores/checkedStore';
import ewMessage from 'ew-message';
import localforage from 'localforage';
import { ewConfirm } from './plugins/ewPopbox';
const AsyncHeader = defineAsyncComponent(
  () => import('./components/Header/Header.vue')
);
const AsyncMain = defineAsyncComponent(
  () => import('./components/Main/Main.vue')
);
const AsyncFooter = defineAsyncComponent(
  () => import('./components/Footer/Footer.vue')
);
const mainType = ref<string>('add');
const editData = ref<NoteDataItem>({});
const memoData = ref<NoteDataItem[]>([]);
const searchCacheMemoData = ref<NoteDataItem[]>([]);
const showCheckBox = ref(false);
const router = useRouter();
const route = useRoute();
const checkedStore = useCheckedStore();
const getMemoData = async () => {
  const memoStoreData = (await useMemoData()) || [];
  memoData.value = [...memoStoreData];
  searchCacheMemoData.value = memoData.value;
  const type = await localforage.getItem<string>('mainType');
  if (type) {
    mainType.value = type;
  }
  // 假如当时处于选中待删去状况,改写页面后重置回未选中待删去状况
  if (type === 'delete') {
    mainType.value = 'add';
  }
};
const onBackHandler = () => {
  mainType.value = 'add';
  localforage.setItem('mainType', mainType.value);
  if (route.name === 'detail') {
    router.push({
      name: 'index'
    });
    showCheckBox.value = false;
    getMemoData();
  }
};
const onHeaderClickHandler = (v: string) => {
  const isCancel = v === 'cancel';
  showCheckBox.value = isCancel;
  mainType.value = isCancel ? 'delete' : 'add';
};
const onFooterClick = async (v: string, isClearEditData: boolean) => {
  if (v === 'editRefresh') {
    mainType.value = 'add';
  }
  if (v !== 'addRefresh') {
    mainType.value = v;
  }
  // 点击新增需求清空修正数据
  if (isClearEditData) {
    editData.value = {};
  }
  // 新增或许修正成功后都需求改写列表
  if (v.toLowerCase().includes('refresh')) {
    getMemoData();
  }
  if (v === 'delete') {
    if (checkedStore.checkedData.length === 0) {
      return ewMessage.warning({
        content: '请选择需求删去的备忘录事项',
        duration: 4000
      });
    }
    ewConfirm({
      title: '温馨提示',
      content: '确认要删去这些备忘录事项吗?',
      showCancel: true,
      sure: async (ctx, e) => {
        e?.stopImmediatePropagation();
        searchCacheMemoData.value = memoData.value =
          searchCacheMemoData.value.filter(
            item => !checkedStore.checkedData.includes(item.id!)
          );
        if (memoData.value.length === 0) {
          mainType.value = 'add';
        }
        await useSetMemoData(memoData.value, false);
        // 删去完结需求清空
        checkedStore.$patch({ checkedData: [] });
        ewMessage.success({
          content: '删去成功',
          duration: 4000
        });
        ctx?.close(600);
        setTimeout(() => getMemoData(), 10);
      }
    });
  }
  localforage.setItem('mainType', mainType.value);
};
const onEditHandler = (id: string) => {
  mainType.value = 'save';
  editData.value = memoData.value.find(item => item.id === id) || {};
};
const onDetailHandler = () => {
  mainType.value = 'detail';
  localforage.setItem('mainType', mainType.value);
};
const onSearchHandler = (v: string) => {
  // if (!v) {
  //     return ewMessage.warning({
  //         content: "请输入需求搜素的内容",
  //         duration: 4000
  //     })
  // }
  const searchMemoData = searchCacheMemoData.value.filter(
    item =>
      item.content?.includes(v) ||
      item.title?.includes(v) ||
      item.classification?.includes(v)
  );
  memoData.value = searchMemoData;
};
onMounted(async () => {
  getMemoData();
});
</script>

接下来咱们对以上代码逐个剖析,总结下来就三步,第一步导入相关依靠或东西函数,第二步界说相关数据状况,第三步增加工作逻辑。

首要咱们运用 defineAsyncComponent 这个办法来异步加载组件,这样做的优点便是懒加载组件,尽可能削减主页的烘托。然后咱们会用一个 searchCacheMemoData 来缓存数据,由于咱们的查找功用需求依据缓存的数据来进行替换。然后还需求留意的便是,在多选删去或许删去数据之后,咱们的缓存的数据也需求更换。其他的像 memoData,editData 等在前面咱们也现已介绍过了,然后咱们界说了一个 getMemoData 办法,这个办法便是获取备忘录数据的,咱们经过 useMemoData 封装好的 hooks 函数来获取数据,然后在 onMounted 钩子函数中调用一次。

然后咱们还需求缓存 mainType,这样能保证假如页面改写后,当时页面所处于哪一种状况不会呈现任何问题。接下来便是每一个工作的逻辑,比方 onBackHandler,在新增/修正/概况页时会呈现该按钮,点击该按钮咱们就会回来到主页,假如是新增/修正页点击回来,咱们只需求修正 mainType 即可,而假如是概况页,咱们就需求跳转路由,而且也需求重置 showCheckBox,还需求从头恳求数据。

接下来是点击头部的图标的工作逻辑,即 onHeaderClickHandler 办法,这个比较简略,那便是在有数据的时分,会显示修正按钮,点击修正按钮,而且修正按钮也变成取消按钮,一起底部就会变成删去按钮,而且会呈现多选框,因而咱们只需求修正 mainType 和 showCheckBox。

然后便是 onFooterClick 办法,也便是点击底部按钮的逻辑,这个办法略微杂乱一点,首要,默认状况下会是新增按钮,因而点击新增按钮的时分,下面要变成保存按钮,这是第一种状况,紧接着假如是第二种状况,那便是点击保存,点击保存也分为两种,是新增保存仍是修正保存,两者都需求改写数据,因而咱们回传了一个带 refresh 的字符串用来代表是否改写数据,也便是从头恳求 getMemoData,假如是新增的时分,咱们还需求重置修正的数据,由于点击修正的时分,咱们是赋值了 editData 的,然后便是点击删去,咱们会给出一个弹窗,点击确认,就获取到选中的 id,然后依据 id 过滤掉数据并从头赋值,删去完结之后,咱们给出一个提示,而且重置咱们选中的 id,当然还要改写列表恳求数据。

接下来是 onEditHandler 办法,顾名思义,这个便是点击修正的时分,在什么状况下呢?那便是单个数据会有修正和删去项,因而也就需求这个办法了。这个办法做的工作也很简略,那便是修正 mainType 的值为 save,然后修正修正数据。

紧接着便是 onDetailHandler 办法,这个办法便是修正 mainType 的值并存储,这个办法是底部传来的,关于概况页的跳转都在底部做了,因而在这儿咱们只需求修正 mainType 的值即可。

最后是咱们的 onSearchHandler 办法,那便是依据查找值过滤掉数据并修正数据即可。

多选框组件

components/CheckBox/CheckBox.vue 下是咱们的多选框组件,代码如下所示:

<script setup lang="ts">
import { computed, ref } from 'vue';
import { checkedIcon } from '../../const/icon';
const emit = defineEmits(['on-change']);
const getCheckBoxIcon = computed(() =>
  checkedIcon('ew-note-checkbox-checked-icon')
);
const isChecked = ref(false);
const onClickHandler = () => {
  isChecked.value = !isChecked.value;
  emit('on-change', isChecked.value);
};
</script>
<template>
  <label
    class="ew-note-checkbox ew-note-flex-center"
    v-html="getCheckBoxIcon"
    :class="{ checked: isChecked }"
    @click="onClickHandler"
  ></label>
</template>
<style scoped>
.ew-note-checkbox {
  width: 28px;
  height: 28px;
  border-radius: 1px;
  border: 1px solid var(--ew-note-checkbox-border-color--);
  margin-right: 5px;
  cursor: pointer;
  color: var(--white--);
}
.ew-note-checkbox.checked {
  background-color: var(--ew-note-checkbox-bg-color--);
}
</style>

依据以上代码,咱们能够看到组件的代码很简略,首要是用 defineEmits 界说一个工作传递给父组件,然后约好一个状况用来操控组件是否是选中状况,其他都是款式和简略的元素。

概况组件

components/Detail/Detail.vue 下便是咱们的概况组件,也很简略,代码如下所示:

<script setup lang="ts">
import { useRoute } from 'vue-router';
import { computed } from 'vue';
const route = useRoute();
const props = withDefaults(defineProps<{ memoData?: NoteDataItem[] }>(), {});
const detailData = computed(
  () => props.memoData?.find(item => item.id === route.params.uuid) || {}
);
</script>
<template>
  <div class="ew-note-detail-container">
    <h1 class="ew-note-detail-title">{{ detailData?.title }}</h1>
    <div class="ew-note-detail-classification">
      {{ detailData?.classification }}
    </div>
    <div class="ew-note-detail-content">{{ detailData?.content }}</div>
    <div class="ew-note-detail-date">
      <p>创立日期: {{ detailData?.createDate }}</p>
      <p>更新日期: {{ detailData?.updateDate }}</p>
    </div>
  </div>
</template>

简略来说便是依据路由的 uuid 来获取当时是哪条备忘录数据,然后烘托到页面即可。

Error 组件

components/Error/Error.vue 代表 404 组件,假如路由未匹配到,就会烘托该组件,该组件代码也很简略,如下所示:

<script setup lang="ts">
import { errorIcon } from '../../const/icon';
import { computed } from 'vue';
const getErrorHTML = computed(
  () => `
    ${errorIcon('ew-note-error-icon')}
    <p>暂未找到该页面!</p>
    <a href="https://juejin.im/"  class="ew-note-error-link">回来主页</a>
`
);
</script>
<template>
  <div class="ew-note-error ew-note-flex-center" v-html="getErrorHTML"></div>
</template>

运用 computed 缓存 html 子元素结构,然后运用 v-html 指令烘托即可,这儿咱们页烘托了错误的图标。

footer 组件

components/Footer/Footer.vue 便是对底部组件的封装,这儿面的代码略微杂乱一点,咱们先看一切代码如下所示:

<script setup lang="ts">
import { computed } from 'vue';
import {
  createUUID,
  formatTime,
  handleClassName,
  isEmptyObject
} from '../../utils/utils';
import { addIcon, deleteIcon, saveIcon } from '../../const/icon';
import { useNoteStore } from '../../stores/noteStore';
import { useMemoData, useSetMemoData } from '../../hooks/useMemoData';
import ewMessage from 'ew-message';
const props = defineProps({
  mainType: String,
  totalNote: Number,
  editData: Object
});
const noteStore = useNoteStore();
const emit = defineEmits(['on-footer-click']);
const getFooterBtnClassName = computed(() => props.mainType);
const getFooterIcon = computed(() => {
  if (props.mainType === 'add') {
    return addIcon('ew-note-add-btn-icon');
  } else if (props.mainType === 'delete') {
    return deleteIcon('ew-note-delete-btn-icon');
  } else {
    return saveIcon('ew-note-save-btn-icon');
  }
});
const addMemoData = async () => {
  if (!noteStore.title) {
    return ewMessage.warning({
      content: '请输入需求记载的事项标题',
      duration: 4000
    });
  }
  if (!noteStore.classification) {
    return ewMessage.warning({
      content: '请输入需求记载的事项分类',
      duration: 4000
    });
  }
  if (!noteStore.content) {
    return ewMessage.warning({
      content: '请输入需求记载的事项内容',
      duration: 4000
    });
  }
  let memoStoreData: NoteDataItem[] = [];
  memoStoreData.push({
    id: createUUID(),
    createDate: formatTime(),
    updateDate: '',
    ...noteStore.$state
  });
  await useSetMemoData(memoStoreData);
  ewMessage.success({
    content: '增加事项成功',
    duration: 2000
  });
  noteStore.clear();
};
const editMemoData = async () => {
  let memoStoreData: Array<NoteDataItem> = await useMemoData();
  memoStoreData = memoStoreData.map(item => {
    if (item.id === props.editData?.id) {
      return {
        ...props.editData,
        ...noteStore.$state,
        updateDate: formatTime()
      };
    } else {
      return item;
    }
  });
  await useSetMemoData(memoStoreData, false);
  ewMessage.success({
    content: '修正事项成功,2s后将跳转至主页',
    duration: 2000
  });
};
const onFooterClickHandler = async () => {
  if (props.mainType === 'add') {
    emit('on-footer-click', 'save', true);
  }
  if (props.mainType === 'save') {
    const isEdit = !isEmptyObject(props.editData);
    const type = isEdit ? 'editRefresh' : 'addRefresh';
    if (isEdit) {
      await editMemoData();
    } else {
      await addMemoData();
    }
    setTimeout(() => {
      emit('on-footer-click', type);
    }, 2100);
  }
  if (props.mainType === 'delete') {
    emit('on-footer-click', 'delete');
  }
};
</script>
<template>
  <footer class="ew-note-footer ew-note-flex-center">
    <h3 class="ew-note-footer-title">
      <span class="ew-note-footer-title-total">{{ props.totalNote || 0 }}</span
      >个备忘录
    </h3>
    <button
      type="button"
      :class="handleClassName(`ew-note-${getFooterBtnClassName}-btn`)"
      class="ew-note-btn"
      v-html="getFooterIcon"
      @click="onFooterClickHandler"
    ></button>
  </footer>
</template>

接下来咱们来逐个剖析,首要咱们先剖析一下 html 元素结构,很简略就包括一个标题,标题会展现有多少个备忘录数据,在前面的 app.vue 咱们也能看到 totalNote 是从父组件传下来的,依据数据 memoData.length 核算而得到的结果。

然后便是按钮元素,按钮元素略微有点杂乱,其实首要是两步,由于按钮元素有保存 save 和新增 add 以及删去 delete 三种状况,因而这儿咱们分别设置了三个动态类名,以及烘托三个图标,不同的按钮元素,点击工作触发的逻辑也有所不同。

然后咱们依据 mainType 的值来判别是触发什么逻辑,假如值是 add,代表咱们点击的是新增,此时咱们应该重置表单,因而需求修正 mainType 的值为 save,并向父组件抛出工作,传递 2 个参数,从前面 app.vue 咱们能够知道第二个参数 boolean 值是用于清除新增表单时的数据,为什么会有这个逻辑呢?试想假如用户是点击修正,此时赋值了修正数据,也就烘托了修正数据,再点击回来取消修正,此时修正数据是没有被重置的,然后咱们再点击新增,那么就会变成修正数据而非新增数据。

从父组件传下来首要有三个字段,即 mainType,totalNote 与 editData,点击新增和删去的逻辑还比较简略,便是向父组件抛出工作并传递相应参数即可,其他逻辑都在父组件那里处理了。

点击保存时会分红两种状况即新增保存和修正保存,新增的时分需求判别是否有值,其实这儿的校验都比较简略,仅仅简略判别是否输入值即可,假如未输入值,则给出提示,不执行后续逻辑,然后新建一个数组,创立一条数据,将相关值增加到数据中,最后调用 useSetMemoData 函数即可,而修正则是获取当时的数据,依据 id 去修正相应的数据即可。

不论是什么保存,终究都需求向父组件抛出一个工作,好让父组件改写页面数据,又或许这儿还做了一个很有意思的功用,那便是新增完结,咱们的页面是不会回到数据列表主页的,可是修正完结是需求跳转的。

然后便是最开端的咱们依据 mainType 来确认烘托的类名和烘托的图标,然后确认是烘托新增按钮仍是保存按钮又或许是删去按钮。

Form 组件

form 组件便是咱们的新增/修正表单元素模版,其代码如下所示:

<script setup lang="ts">
import { watch } from 'vue';
import { isObject, isString } from '../../utils/utils';
import { useNoteStore } from '../../stores/noteStore';
import { updateFormKeys } from '../../const/keys';
const props = withDefaults(
  defineProps<{ editData?: Partial<NoteDataItem> }>(),
  {
    editData: undefined
  }
);
const noteStore = useNoteStore();
watch(
  () => props.editData,
  val => {
    if (isObject(val)) {
      updateFormKeys.forEach(key => {
        const value = val![key as keyof NoteDataItem];
        const store = {
          [key]: isString(value) ? value : ''
        };
        noteStore.$patch(store);
      });
    }
  },
  { immediate: true }
);
const onChangeForm = (v: Event) => {
  const target = v.target as HTMLElement;
  if (target) {
    const key = target.getAttribute('name') as keyof NoteFormDataItem;
    const value = target.textContent;
    if (key && value) {
      noteStore.$patch({
        [key]: value
      });
    }
  }
};
</script>
<template>
  <div
    contenteditable="true"
    class="ew-note-input ew-note-input-title"
    placeholder="请输入需求记载的事项标题"
    @input="onChangeForm"
    name="title"
  >
    {{ noteStore.title }}
  </div>
  <div
    contenteditable="true"
    class="ew-note-input ew-note-input-class"
    placeholder="请输入需求记载的事项分类"
    @input="onChangeForm"
    name="classification"
  >
    {{ noteStore.classification }}
  </div>
  <div
    contenteditable="true"
    class="ew-note-textarea ew-note-textarea-content"
    placeholder="请输入需求记载的事项内容"
    @input="onChangeForm"
    name="content"
  >
    {{ noteStore.content }}
  </div>
</template>

以上咱们烘托了三个 div 元素并设置了 contenteditable 为 true 能够让元素像表单元素那样被修正,然后绑定了相应的数据值,这儿有一点便是咱们增加了一个 name 特点,用来确认用户输入的是哪个字段的值。

然后咱们监听是否有修正数据,假如有就赋值,没有便是空值,能够看到,这儿咱们是经过 pinia 将表单数据放置在 store 里边的,因而这儿咱们运用的是 store.$patch 来赋值。

同样的咱们监听三个 div 元素的 input 工作,也相同是修正 store。

Header 组件

components/Header/Header.vue 代表头部组件,其代码如下:

<script setup lang="ts">
import { backIcon, cancelIcon, editIcon } from '../../const/icon';
import { handleClassName } from '../../utils/utils';
import { ref, computed, watch } from 'vue';
const props = defineProps({
  mainType: String,
  memoData: Array
});
const emit = defineEmits(['on-back', 'on-header-click']);
const headerIconType = ref('');
const getHeaderIcon = computed(() => {
  if (headerIconType.value === 'edit') {
    return editIcon('ew-note-edit-btn-icon');
  } else if (headerIconType.value === 'cancel') {
    return cancelIcon('ew-note-cancel-btn-icon');
  } else {
    return '';
  }
});
const onBackHandler = () => {
  emit('on-back');
};
const onHeaderClick = () => {
  const val = headerIconType.value;
  if (val === '') {
    return;
  }
  headerIconType.value = val === 'edit' ? 'cancel' : 'edit';
  emit('on-header-click', headerIconType.value);
};
watch(
  [() => props.mainType, () => props.memoData],
  val => {
    const [mainType, memoData] = val;
    const noData = Array.isArray(memoData) && memoData.length;
    if (mainType === 'add' && noData) {
      headerIconType.value = 'edit';
    } else if (!noData || (mainType !== 'add' && mainType !== 'delete')) {
      headerIconType.value = '';
    }
  },
  { immediate: true }
);
</script>
<template>
  <header class="ew-note-header ew-note-flex-center">
    <button
      type="button"
      class="ew-note-btn ew-note-back-btn"
      v-html="backIcon('ew-note-back-btn-icon')"
      v-if="['save', 'detail'].includes(props.mainType!)"
      @click="onBackHandler"
    ></button>
    <h3 class="ew-note-header-title">备忘录</h3>
    <button
      type="button"
      :class="
        handleClassName(
          `ew-note-${headerIconType === 'edit' ? 'edit' : 'cancel'}-btn`
        )
      "
      class="ew-note-btn"
      v-html="getHeaderIcon"
      v-if="headerIconType"
      @click="onHeaderClick"
    ></button>
  </header>
</template>

与 footer.vue 里边的逻辑有点相似,头部首要烘托标题,回来按钮和修正/取消按钮。这儿值得说一下的便是,假如没有数据,咱们是不需求烘托修正按钮的,而且 mainType 假如不是 add(主页默认该值便是 add),同样也是不需求烘托修正按钮,因而,这儿咱们监听了从父组件传来的 memoData 和 mainType 两个字段的值。

别的还有一个逻辑便是回来按钮只要在当时是概况页或许当时是新增/修正(即 mainType 为 save)的时分才会烘托。

List 组件

我将 Main 组件还做了拆分,里边假如是烘托数据即主页的话,那么就需求用到该组件,该组件代码如下所示:

<script setup lang="ts">
import { computed, defineAsyncComponent, ref } from 'vue';
import {
  arrowRightIcon,
  deleteIcon,
  editIcon,
  emptyDataIcon
} from '../../const/icon';
import { ewConfirm } from '../../plugins/ewPopbox';
import { useMemoData, useSetMemoData } from '../../hooks/useMemoData';
import { useRouter } from 'vue-router';
import { useCheckedStore } from '../../stores/checkedStore';
const checkedStore = useCheckedStore();
const AsyncCheckBox = defineAsyncComponent(
  () => import('../CheckBox/CheckBox.vue')
);
const router = useRouter();
const emit = defineEmits(['on-delete', 'on-detail', 'on-edit']);
const props = withDefaults(
  defineProps<{
    memoData?: NoteDataItem[];
    mainType: string;
    showCheckBox: boolean;
  }>(),
  {
    mainType: 'add',
    showCheckBox: false
  }
);
const handleBtnIcon = computed(
  () => `
${deleteIcon('ew-note-main-content-list-item-delete-icon')}
${editIcon('ew-note-main-content-list-item-edit-icon')}
${arrowRightIcon('ew-note-main-content-list-item-right-icon')}
`
);
const noEmptyData = computed(
  () => `
${emptyDataIcon('ew-note-main-no-data-icon')}
<p class="ew-note-main-no-data-text">暂无数据</p>
`
);
const checkedData = ref<string[]>([]);
const onChangeHandler = (e: boolean, v: string) => {
  if (e) {
    checkedData.value.push(v);
  } else {
    checkedData.value = checkedData.value.filter(item => item !== v);
  }
  checkedStore.$patch({ checkedData: checkedData.value });
};
const toDetailHandler = (data: NoteDataItem) => {
  router.push({
    name: 'detail',
    params: {
      uuid: data.id
    }
  });
  emit('on-detail');
};
const onClickHandler = (e: Event, data: NoteDataItem) => {
  e.stopPropagation();
  const target = e.target as HTMLElement;
  if (target) {
    const newTarget =
      target.tagName.toLowerCase() === 'path' ? target?.parentElement : target;
    const classNames = (newTarget as unknown as SVGElement).classList;
    if (classNames.contains('ew-note-main-content-list-item-delete-icon')) {
      ewConfirm({
        title: '温馨提示',
        content: '确认要删去该数据吗?',
        showCancel: true,
        sure: async ctx => {
          let memoStoreData: Array<NoteDataItem> = await useMemoData();
          const memoNewStoreData = memoStoreData.filter(
            item => item.id !== data.id
          );
          await useSetMemoData(memoNewStoreData, false);
          ctx?.close(600);
          emit('on-delete');
        }
      });
    } else if (
      classNames.contains('ew-note-main-content-list-item-edit-icon')
    ) {
      emit('on-edit', data.id);
    } else {
      toDetailHandler(data);
    }
  }
};
const onGoToDetail = (e: Event, data: NoteDataItem) => {
  e.stopPropagation();
  toDetailHandler(data);
};
</script>
<template>
  <ul class="ew-note-main-content-list">
    <li
      class="ew-note-main-content-list-item"
      v-for="data in props.memoData || []"
      :key="data.id"
    >
      <async-check-box
        @on-change="onChangeHandler($event, data.id!)"
        v-if="showCheckBox"
      ></async-check-box>
      <a
        href="javascript:void 0;"
        :data-url="`/detail?uuid=${data.id}`"
        class="ew-note-main-content-list-item-link"
        rel="noopener noreferrer"
        @click="onGoToDetail($event, data)"
      >
        <p class="ew-note-main-content-list-item-title">{{ data.title }}</p>
        <p class="ew-note-main-content-list-item-date">
          <span class="ew-note-main-content-list-item-create-date"
            >创立日期:{{ data.createDate }}</span
          >
          <span class="ew-note-main-content-list-item-update-date"
            >更新日期:{{ data.updateDate }}</span
          >
        </p>
        <div
          class="ew-note-main-content-list-item-btn-group"
          v-html="handleBtnIcon"
          @click="onClickHandler($event, data)"
        ></div>
      </a>
    </li>
  </ul>
  <div
    class="ew-note-main-no-data-container ew-note-flex-center"
    v-html="noEmptyData"
    v-if="!props.memoData?.length && props.mainType === 'add'"
  ></div>
</template>

这个组件的逻辑也不多,便是单个修正,删去,多选框选中以及跳转到概况的逻辑,点击右箭头按钮或许整个超链接元素,都需求跳转到概况,因而咱们封装了一个 toDetailHandler 办法。

main 组件

接下来咱们来看 main 组件,components/Main/Main.vue 下,代码如下所示:

<script setup lang="ts">
import { defineAsyncComponent, onMounted } from 'vue';
import { $, toTop } from '../../utils/utils';
const AsyncForm = defineAsyncComponent(() => import('../Form/Form.vue'));
const AsyncSearch = defineAsyncComponent(() => import('../Search/search.vue'));
const props = withDefaults(
  defineProps<{
    mainType: string;
    memoData?: NoteDataItem[];
    editData?: NoteDataItem;
    showCheckBox: boolean;
  }>(),
  {
    mainType: '',
    showCheckBox: false
  }
);
onMounted(() => {
  const topElement = $('.ew-note-to-top') as HTMLDivElement;
  const mainElement = $('.ew-note-main') as HTMLElement;
  if (topElement) {
    toTop(topElement, mainElement);
  }
});
</script>
<template>
  <main class="ew-note-main">
    <async-form
      v-if="['save', 'edit'].includes(props.mainType)"
      :editData="props.editData"
    ></async-form>
    <async-search
      @on-search="$emit('on-search', $event)"
      v-if="['add', 'delete'].includes(props.mainType)"
    ></async-search>
    <router-view
      :memoData="props.memoData"
      :mainType="props.mainType"
      @on-detail="$emit('on-detail')"
      @on-delete="$emit('on-delete')"
      @on-edit="(id: string) => $emit('on-edit', id)"
      :showCheckBox="showCheckBox"
      v-if="props.mainType !== 'save'"
    ></router-view>
    <div class="ew-note-to-top"></div>
  </main>
</template>

main 组件也便是烘托了新增/修正表单,路由,以及回到顶部按钮,其间路由咱们也将烘托组件抛出的工作继续向付组件抛出,当然这儿也需求留意工作参数的传递,然后便是在 onMounted 钩子函数中,咱们调用了回到顶部按钮工作相关逻辑办法 toTop。

Search.vue

components/Search/Search.vue 代码如下所示:

<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { searchIcon } from '../../const/icon';
const emit = defineEmits(['on-search']);
const searchValue = ref('');
const getSearchIcon = computed(() => searchIcon('ew-note-search-icon'));
const onSearchHandler = () => {
  emit('on-search', searchValue.value);
};
</script>
<template>
  <div class="ew-note-search">
    <input
      type="text"
      v-model="searchValue"
      placeholder="请输入您需求查找的备忘录事项"
      class="ew-note-search-input"
      @keydown.enter="$emit('on-search', searchValue)"
    />
    <span
      v-html="getSearchIcon"
      class="ew-note-search-icon-container"
      @click="onSearchHandler"
    ></span>
  </div>
</template>
<style scoped>
.ew-note-search {
  display: flex;
  align-items: center;
  position: relative;
}
.ew-note-search-input {
  border-radius: 6px;
  padding: 8px 12px;
  width: 100%;
  display: inline-block;
  outline: none;
  border: 1px solid var(--search-border-color--);
  color: var(--search-color--);
}
.ew-note-search-input:focus {
  border-color: var(--search-focus-color--);
}
.ew-note-search-icon-container {
  position: absolute;
  right: 4px;
  display: flex;
  align-items: center;
}
</style>

也便是烘托一个查找框和查找图标元素,然后监听按下键盘 enter 工作和点击查找图标,咱们将工作抛出给父组件。

总结

尽管这仅仅一个小小的备忘录,但咱们能够看到这个小小的备忘录项目简直用到了 vue 的常用语法以及相关生态(vue 路由和 vue 状况办理东西 pinia),这对咱们娴熟运用 vue3 的语法仍是很有协助的。总结知识点如下:

  1. vue3 根底语法
  2. vue3 状况办理东西
  3. vue3 路由
  4. localforage 的运用
  5. 弹出框插件的完结
  6. 一些表单数据的增修改查业务逻辑

假如觉得本文有所协助,望不小气点赞收藏,想看源码,能够前往此处

ps: 万丈高楼平地起,尽管备忘录还很简略,可是假以时日,不断的扩展更新,也相同能够算作是一个代表性的实战项目。

最后,感谢我们阅读。