从 0 到 1 搭建 Vue 前端工程框架 | 第五篇:项目初始化配置

339 阅读12分钟

前言

完善一下项目初始化配置,分为store配置、初始化内部系统配置、注册全局组件、配置路由、路由守卫、注册全局指令、配置全局错误处理这些内容。

1、配置 store

在src/stores文件夹下新建一个index.ts,顺便删除原有的counter.ts

// src/stores/index.ts
import type { App } from 'vue';
import { createPinia } from 'pinia';

const store = createPinia();

export function setupStore(app: App<Element>) {
  app.use(store);
}

export { store };

更改一下main.ts里有关store的部分

// main.ts
import './style/tailwind.scss';
import './assets/main.css';
import { createApp } from 'vue';

import App from './App.vue';
import router from './router';

import { setupStore } from '/@/stores';

async function bootstrap() {
  const app = createApp(App);

  // 配置 store
  setupStore(app);

  app.use(router);

  app.mount('#app');
}

bootstrap();

注意这里的bootstrap函数,后面几步的setupXXX都会在这里调用 接着在stores文件夹下新建modules文件夹,里面新建app.ts文件用来存放项目配置

// app.ts
import { defineStore } from 'pinia';
import { store } from '/@/stores';
import { ProjectConfig } from '/#/config';
import { PROJ_CFG_KEY } from '/@/enums/cacheEnum';
import { Persistent } from '/@/utils/cache/persistent';
import { deepMerge } from '/@/utils';

// app基本配置
interface AppState {
  // 页面加载状态
  pageLoading: boolean;
  // 项目配置
  projectConfig: ProjectConfig | null;
}
export const useAppStore = defineStore({
  id: 'app',
  state: (): AppState => ({
    pageLoading: false,
    projectConfig: Persistent.getLocal(PROJ_CFG_KEY),
  }),
  getters: {
    getPageLoading(state): boolean {
      return state.pageLoading;
    },
    getProjectConfig(state): ProjectConfig {
      return state.projectConfig || ({} as ProjectConfig);
    },
  },
  actions: {
    setPageLoading(loading: boolean): void {
      this.pageLoading = loading;
    },
    setProjectConfig(config: DeepPartial<ProjectConfig>): void {
      this.projectConfig = deepMerge(this.projectConfig || {}, config) as ProjectConfig;
      Persistent.setLocal(PROJ_CFG_KEY, this.projectConfig);
    },
  },
});

// 需要在设置之外使用
export function useAppStoreWithOut() {
  return useAppStore(store);
}


这里面用到的utils函数在上一篇utils配置中有提到 使用场景

// demo
import { useAppStoreWithOut } from '/@/stores/modules/app';
const appStore = useAppStoreWithOut();
appStore.setPageLoading(false);

2、初始化内部系统配置

新建/@/logics/initAppConfig.ts文件

// initAppConfig.ts
/**
 * 应用程序配置
 */
import type { ProjectConfig } from '/#/config';

import { PROJ_CFG_KEY } from '/@/enums/cacheEnum';
import projectSetting from '/@/settings/projectSetting';

import { useAppStore } from '/@/stores/modules/app';

import { getCommonStoragePrefix, getStorageShortName } from '/@/utils/env';

import { Persistent } from '/@/utils/cache/persistent';
import { deepMerge } from '/@/utils';

// 初始项目配置
export function initAppConfigStore() {
  const appStore = useAppStore();
  let projCfg: ProjectConfig = Persistent.getLocal(PROJ_CFG_KEY) as ProjectConfig;

  projCfg = deepMerge(projectSetting, projCfg || {});

  appStore.setProjectConfig(projCfg);

  setTimeout(() => {
    clearObsoleteStorage();
  }, 16);
}

/**
 * 随着版本的不断迭代,localStorage中存储的缓存密钥将越来越多
 * 此方法用于删除无用的密钥
 */
export function clearObsoleteStorage() {
  const commonPrefix = getCommonStoragePrefix();
  const shortPrefix = getStorageShortName();

  [localStorage, sessionStorage].forEach((item: Storage) => {
    Object.keys(item).forEach((key) => {
      if (key && key.startsWith(commonPrefix) && !key.startsWith(shortPrefix)) {
        item.removeItem(key);
      }
    });
  });
}

/@/settings/projectSetting这个文件需要新建一下。

// projectSetting.ts
import type { ProjectConfig } from '/#/config';
import { SessionTimeoutProcessingEnum } from '/@/enums/appEnum';
// ! 更改后需要清除浏览器缓存
const setting: ProjectConfig = {
  // 会话超时处理
  sessionTimeoutProcessing: SessionTimeoutProcessingEnum.ROUTE_JUMP,

  // 使用错误处理程序插件
  useErrorHandle: false,

  // 切换接口时是否删除未关闭的消息并通知
  closeMessageOnSwitch: true,

  // 切换路由时是否取消已发送但未响应的http请求
  // 如果启用了它,我想覆盖单个接口。可以在单独的界面中设置
  removeAllHttpPending: false,
};

export default setting;

/@/enums/appEnum这个文件需要新建一下。

// appEnum.ts
// 会话超时处理
export enum SessionTimeoutProcessingEnum {
  ROUTE_JUMP,
  PAGE_COVERAGE,
}

更改一下main.ts文件。

// main.ts
import './style/tailwind.scss';
import './assets/main.css';
import { createApp } from 'vue';

import App from './App.vue';
import router from './router';

import { setupStore } from '/@/stores';
import { initAppConfigStore } from './logics/initAppConfig';

async function bootstrap() {
  const app = createApp(App);

  // 配置 store
  setupStore(app);

  // 初始化内部系统配置
  initAppConfigStore();

  app.use(router);

  app.mount('#app');
}

bootstrap();

3、注册全局组件

这部分我采用的全局引入的方式,如果需要按需引入,按照组件库官网提供的方式修改即可,也可以用我自己常用的方法。 新建/@/components/registerGlobComp.ts文件。

// registerGlobComp.ts
import type { App } from 'vue';
import VXETable from 'vxe-table';
import ElementPlus from 'element-plus';
import 'xe-utils';

export function registerGlobComp(app: App) {
  app.use(VXETable).use(ElementPlus);
}

安装组件库

// package.json
"vxe-table": "^4.3.12",
"element-plus": "^2.3.4",
"xe-utils": "^3.5.7",

如果需要按需引入,registerGlobComp.ts文件可以这样写。

// registerGlobComp.ts
import type { App } from 'vue';
import VXETable from 'vxe-table';
import { ElButton, ElAlert } from 'element-plus';
import 'xe-utils';

export function registerGlobComp(app: App) {
  app.use(VXETable).use(ElButton).use(ElAlert);
}

修改一下main.ts

// main.ts
import './style/tailwind.scss';
import './assets/main.css';
import { createApp } from 'vue';

import App from './App.vue';
import router from './router';

import { setupStore } from '/@/stores';
import { initAppConfigStore } from './logics/initAppConfig';
import { registerGlobComp } from './components/registerGlobComp';

async function bootstrap() {
  const app = createApp(App);

  // 配置 store
  setupStore(app);

  // 初始化内部系统配置
  initAppConfigStore();

  // 注册全局组件
  registerGlobComp(app);

  app.use(router);

  app.mount('#app');
}

bootstrap();

4、配置路由

把原有的文件删掉,重新按照下面的结构新建一个。 在这里插入图片描述 直接上代码。

// /@/router/routes/modules/about.ts
// 路由示例
const abouteDemo = {
  path: '/about',
  name: 'about',
  // route level code-splitting
  // this generates a separate chunk (About.[hash].js) for this route
  // which is lazy-loaded when the route is visited.
  component: () => import('/@/views/AboutView.vue'),
};

export default abouteDemo;

// /@/router/routes/modules/home.ts
import HomeView from '/@/views/HomeView.vue';
// 路由示例
const homeDemo = {
  path: '/home',
  name: 'home',
  // route level code-splitting
  // this generates a separate chunk (About.[hash].js) for this route
  // which is lazy-loaded when the route is visited.
  component: () => import('/@/views/HomeView.vue'),
};

export default homeDemo;
	
// /@/router/routes/index.ts
import { AppRouteModule, AppRouteRecordRaw } from '/@/router/types';
import { PageEnum } from '/@/enums/pageEnum';

const modules = import.meta.glob('./modules/**/*.ts', { import: 'default', eager: true });
const routeModuleList: AppRouteModule[] = [];

Object.keys(modules).forEach((key) => {
  const mod = modules[key] || {};
  const modList = Array.isArray(mod) ? [...mod] : [mod];
  routeModuleList.push(...modList);
});

// 根路由
export const RootRoute: AppRouteRecordRaw = {
  path: '/',
  name: 'Root',
  redirect: PageEnum.BASE_HOME,
  meta: {
    title: 'Root',
  },
};

// 未经许可的基本路由
export const basicRoutes = [RootRoute, ...routeModuleList];

/@/enums/pageEnum这个文件需要新建一下。

// /@/enums/pageEnum
export enum PageEnum {
  // basic home path
  BASE_HOME = '/home',
}

// /@/router/index.ts
import { RouteRecordRaw, createRouter, createWebHashHistory } from 'vue-router';
import { basicRoutes } from './routes';
import type { App } from 'vue';

// 白名单应该包含基本静态路由
const WHITE_NAME_LIST: string[] = [];
const getRouteNames = (array: any[]) =>
  array.forEach((item) => {
    WHITE_NAME_LIST.push(item.name);
    getRouteNames(item.children || []);
  });
getRouteNames(basicRoutes);

export const router = createRouter({
  // hash 历史记录
  // history: createWebHistory(import.meta.env.VITE_PUBLIC_PATH),
  history: createWebHashHistory(import.meta.env.VITE_PUBLIC_PATH),
  // 应该添加到路由的初始路由列表。
  routes: basicRoutes as unknown as RouteRecordRaw[],
  // 是否应该禁止尾部斜杠。默认为假
  strict: true,
  // 路由滚动行为
  scrollBehavior: () => ({ left: 0, top: 0 }),
});

// 重置路由
export function resetRouter() {
  router.getRoutes().forEach((route) => {
    const { name } = route;
    if (name && !WHITE_NAME_LIST.includes(name as string)) {
      router.hasRoute(name) && router.removeRoute(name);
    }
  });
}

// 配置路由器
export function setupRouter(app: App<Element>) {
  app.use(router);
}

// /@/router/types.ts
import type { RouteRecordRaw, RouteMeta } from 'vue-router';
import { defineComponent } from 'vue';

export type Component<T = any> =
  | ReturnType<typeof defineComponent>
  | (() => Promise<typeof import('*.vue')>)
  | (() => Promise<T>);

// @ts-ignore
export interface AppRouteRecordRaw extends Omit<RouteRecordRaw, 'meta'> {
  name: string;
  meta: RouteMeta;
  component?: Component | string;
  components?: Component;
  children?: AppRouteRecordRaw[];
  props?: Recordable;
  fullPath?: string;
}

export type AppRouteModule = AppRouteRecordRaw;	

5、路由导航守卫

因为是模板项目,所以导航守卫简单做一下。 新建/@/router/guard/index.ts文件。

// /@/router/guard/index.ts
import type { RouteLocationNormalized, Router } from 'vue-router';
import projectSetting from '/@/settings/projectSetting';
import { AxiosCanceler } from '/@/utils/http/axios/axiosCancel';

// 注意,这里不要更改创建顺序
export function setupRouterGuard(router: Router) {
  createPageGuard(router);
  createHttpGuard(router);
  createScrollGuard(router);
}

/**
 * 用于处理页面状态的挂钩
 */
function createPageGuard(router: Router) {
  const loadedPageMap = new Map<string, boolean>();

  router.beforeEach(async (to) => {
    // 页面已经加载,再次打开会更快,不需要进行加载和其他处理
    to.meta.loaded = !!loadedPageMap.get(to.path);

    return true;
  });

  router.afterEach((to) => {
    loadedPageMap.set(to.path, true);
  });
}

/**
 * 路由切换时用于关闭当前页面已完成请求的接口
 * @param router
 */
function createHttpGuard(router: Router) {
  const { removeAllHttpPending } = projectSetting;
  let axiosCanceler: Nullable<AxiosCanceler>;
  if (removeAllHttpPending) {
    axiosCanceler = new AxiosCanceler();
  }
  router.beforeEach(async () => {
    // 切换路由将删除以前的请求
    axiosCanceler?.removeAllPending();
    return true;
  });
}

// 回顶部的路由开关
// 只在hash路由模式下触发
function createScrollGuard(router: Router) {
  const isHash = (href: string) => {
    // 匹配hash路由的#
    // 替换createWebHistory为createWebHashHistory即可更换为hash路由
    return /^#/.test(href);
  };

  const body = document.body;

  router.afterEach(async (to) => {
    // 滚动到顶部
    isHash((to as RouteLocationNormalized & { href: string })?.href) && body.scrollTo(0, 0);

    return true;
  });
}

新建/@/utils/http/axios/axiosCancel,后面封装axios也会用到。

// /@/utils/http/axios/axiosCancel
import type { AxiosRequestConfig, Canceler } from 'axios';
import axios from 'axios';
import { isFn } from '/@/utils/is';

// 用于存储每个请求的识别和取消功能
let pendingMap = new Map<string, Canceler>();

export const getPendingUrl = (config: AxiosRequestConfig) => [config.method, config.url].join('&');

export class AxiosCanceler {
  /**
   * Add request
   * @param {Object} config
   */
  addPending(config: AxiosRequestConfig) {
    this.removePending(config);
    const url = getPendingUrl(config);
    config.cancelToken =
      config.cancelToken ||
      new axios.CancelToken((cancel) => {
        if (!pendingMap.has(url)) {
          // 如果当前没有挂起的请求,请添加它
          pendingMap.set(url, cancel);
        }
      });
  }

  /**
   * @description: clear所有在pending的请求
   */
  removeAllPending() {
    pendingMap.forEach((cancel) => {
      cancel && isFn(cancel) && cancel();
    });
    pendingMap.clear();
  }

  /**
   * 删除请求
   * @param {Object} config
   */
  removePending(config: AxiosRequestConfig) {
    const url = getPendingUrl(config);

    if (pendingMap.has(url)) {
      // 如果挂起中有当前请求标识符,
      // 需要取消并删除当前请求
      const cancel = pendingMap.get(url);
      cancel && cancel(url);
      pendingMap.delete(url);
    }
  }

  /**
   * @description: reset
   */
  reset(): void {
    pendingMap = new Map<string, Canceler>();
  }
}

添加axios依赖

// package.json
"axios": "^1.4.0",

导航守卫里还可以使用先前封装的mitt对route进行监听、处理页面加载状态、用户权限、进入login页清除用户信息等,后续根据需要再加,现在是模板版本不需要那么复杂。 修改main.ts。

// main.ts
import './style/tailwind.scss';
import './assets/main.css';
import { createApp } from 'vue';
import App from './App.vue';

import { router, setupRouter } from './router';
import { setupStore } from '/@/stores';
import { initAppConfigStore } from './logics/initAppConfig';
import { registerGlobComp } from './components/registerGlobComp';
import { setupRouterGuard } from '/@/router/guard';

async function bootstrap() {
  const app = createApp(App);

  // 配置 store
  setupStore(app);

  // 初始化内部系统配置
  initAppConfigStore();

  // 注册全局组件
  registerGlobComp(app);

  // 配置路由
  setupRouter(app);

  // 路由守卫
  setupRouterGuard(router);

  app.mount('#app');
}

bootstrap();

6、注册全局指令

新建/@/directives/index.ts文件。

// /@/directives/index.ts
/**
 * 配置和注册全局指令
 */
import type { App } from 'vue';
import { setupRepeatDirective } from './repeatClick';
import { setupRippleDirective } from './ripple';
import { setupClickOutsideDirective } from './clickOutside';

export function setupGlobDirectives(app: App) {
  // 防止重复点击
  setupRepeatDirective(app);
  // 水波纹
  setupRippleDirective(app);
  // 点内外部触发不同事件
  setupClickOutsideDirective(app);
}

这三个是常用的自定义指令,下面直接上代码。

// repeatClick.ts
/**
 * 防止重复点击,注意和防抖节流区分开
 * @Example v-repeat-click="()=>{}"
 */
import { on, once } from '/@/utils/domUtils';
import type { App, Directive, DirectiveBinding } from 'vue';

const RepeatDirective: Directive = {
  beforeMount(el: Element, binding: DirectiveBinding<any>) {
    let interval: Nullable<IntervalHandle> = null;
    let startTime = 0;
    const handler = (): void => binding?.value();
    const clear = (): void => {
      if (Date.now() - startTime < 100) {
        handler();
      }
      interval && clearInterval(interval);
      interval = null;
    };

    on(el, 'mousedown', (e: MouseEvent): void => {
      if ((e as any).button !== 0) return;
      startTime = Date.now();
      once(document as any, 'mouseup', clear);
      interval && clearInterval(interval);
      interval = setInterval(handler, 100);
    });
  },
};

export function setupRepeatDirective(app: App) {
  app.directive('repeat-click', RepeatDirective);
}

export default RepeatDirective;

// ripple/index.ts
import type { App, Directive } from 'vue';
import './index.scss';
export interface RippleOptions {
  event: string;
  transition: number;
}

export interface RippleProto {
  background?: string;
  zIndex?: string;
}

export type EventType = Event & MouseEvent & TouchEvent;

const options: RippleOptions = {
  event: 'mousedown',
  transition: 400,
};

const RippleDirective: Directive & RippleProto = {
  beforeMount: (el: HTMLElement, binding) => {
    if (binding.value === false) return;

    const bg = el.getAttribute('ripple-background');
    setProps(Object.keys(binding.modifiers), options);

    const background = bg || RippleDirective.background;
    const zIndex = RippleDirective.zIndex;

    el.addEventListener(options.event, (event: EventType) => {
      rippler({
        event,
        el,
        background,
        zIndex,
      });
    });
  },
  updated(el, binding) {
    if (!binding.value) {
      el?.clearRipple?.();
      return;
    }
    const bg = el.getAttribute('ripple-background');
    el?.setBackground?.(bg);
  },
};

function rippler({
  event,
  el,
  zIndex,
  background,
}: { event: EventType; el: HTMLElement } & RippleProto) {
  const targetBorder = parseInt(getComputedStyle(el).borderWidth.replace('px', ''));
  const clientX = event.clientX || event.touches[0].clientX;
  const clientY = event.clientY || event.touches[0].clientY;

  const rect = el.getBoundingClientRect();
  const { left, top } = rect;
  const { offsetWidth: width, offsetHeight: height } = el;
  const { transition } = options;
  const dx = clientX - left;
  const dy = clientY - top;
  const maxX = Math.max(dx, width - dx);
  const maxY = Math.max(dy, height - dy);
  const style = window.getComputedStyle(el);
  const radius = Math.sqrt(maxX * maxX + maxY * maxY);
  const border = targetBorder > 0 ? targetBorder : 0;

  const ripple = document.createElement('div');
  const rippleContainer = document.createElement('div');

  // Styles for ripple
  ripple.className = 'ripple';

  Object.assign(ripple.style ?? {}, {
    marginTop: '0px',
    marginLeft: '0px',
    width: '1px',
    height: '1px',
    transition: `all ${transition}ms cubic-bezier(0.4, 0, 0.2, 1)`,
    borderRadius: '50%',
    pointerEvents: 'none',
    position: 'relative',
    zIndex: zIndex ?? '9999',
    backgroundColor: background ?? 'rgba(0, 0, 0, 0.12)',
  });

  // Styles for rippleContainer
  rippleContainer.className = 'ripple-container';
  Object.assign(rippleContainer.style ?? {}, {
    position: 'absolute',
    left: `${0 - border}px`,
    top: `${0 - border}px`,
    height: '0',
    width: '0',
    pointerEvents: 'none',
    overflow: 'hidden',
  });

  const storedTargetPosition =
    el.style.position.length > 0 ? el.style.position : getComputedStyle(el).position;

  if (storedTargetPosition !== 'relative') {
    el.style.position = 'relative';
  }

  rippleContainer.appendChild(ripple);
  el.appendChild(rippleContainer);

  Object.assign(ripple.style, {
    marginTop: `${dy}px`,
    marginLeft: `${dx}px`,
  });

  const {
    borderTopLeftRadius,
    borderTopRightRadius,
    borderBottomLeftRadius,
    borderBottomRightRadius,
  } = style;
  Object.assign(rippleContainer.style, {
    width: `${width}px`,
    height: `${height}px`,
    direction: 'ltr',
    borderTopLeftRadius,
    borderTopRightRadius,
    borderBottomLeftRadius,
    borderBottomRightRadius,
  });

  setTimeout(() => {
    const wh = `${radius * 2}px`;
    Object.assign(ripple.style ?? {}, {
      width: wh,
      height: wh,
      marginLeft: `${dx - radius}px`,
      marginTop: `${dy - radius}px`,
    });
  }, 0);

  function clearRipple() {
    setTimeout(() => {
      ripple.style.backgroundColor = 'rgba(0, 0, 0, 0)';
    }, 250);

    setTimeout(() => {
      rippleContainer?.parentNode?.removeChild(rippleContainer);
    }, 850);
    el.removeEventListener('mouseup', clearRipple, false);
    el.removeEventListener('mouseleave', clearRipple, false);
    el.removeEventListener('dragstart', clearRipple, false);
    setTimeout(() => {
      let clearPosition = true;
      for (let i = 0; i < el.childNodes.length; i++) {
        if ((el.childNodes[i] as Recordable).className === 'ripple-container') {
          clearPosition = false;
        }
      }

      if (clearPosition) {
        el.style.position = storedTargetPosition !== 'static' ? storedTargetPosition : '';
      }
    }, options.transition + 260);
  }

  if (event.type === 'mousedown') {
    el.addEventListener('mouseup', clearRipple, false);
    el.addEventListener('mouseleave', clearRipple, false);
    el.addEventListener('dragstart', clearRipple, false);
  } else {
    clearRipple();
  }

  (el as Recordable).setBackground = (bgColor: string) => {
    if (!bgColor) {
      return;
    }
    ripple.style.backgroundColor = bgColor;
  };
}

function setProps(modifiers: Recordable, props: Recordable) {
  modifiers.forEach((item: Recordable) => {
    if (isNaN(Number(item))) props.event = item;
    else props.transition = item;
  });
}

export function setupRippleDirective(app: App) {
  app.directive('ripple', RippleDirective);
}

export default RippleDirective;

// ripple/index.scss
.ripple-container {
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  overflow: hidden;
  pointer-events: none;
}

.ripple-effect {
  position: relative;
  z-index: 9999;
  width: 1px;
  height: 1px;
  margin-top: 0;
  margin-left: 0;
  pointer-events: none;
  border-radius: 50%;
  transition: all 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}

// clickOutside.ts
import { on } from '/@/utils/domUtils';
import { isServer } from '/@/utils/is';
import type { App, ComponentPublicInstance, DirectiveBinding, ObjectDirective } from 'vue';

type DocumentHandler = <T extends MouseEvent>(mouseup: T, mousedown: T) => void;

type FlushList = Map<
  HTMLElement,
  {
    documentHandler: DocumentHandler;
    bindingFn: (...args: unknown[]) => unknown;
  }
>;

const nodeList: FlushList = new Map();

let startClick: MouseEvent;

if (!isServer) {
  on(document, 'mousedown', (e: MouseEvent) => (startClick = e));
  on(document, 'mouseup', (e: MouseEvent) => {
    for (const { documentHandler } of nodeList.values()) {
      documentHandler(e, startClick);
    }
  });
}

function createDocumentHandler(el: HTMLElement, binding: DirectiveBinding): DocumentHandler {
  let excludes: HTMLElement[] = [];
  if (Array.isArray(binding.arg)) {
    excludes = binding.arg;
  } else {
    // due to current implementation on binding type is wrong the type casting is necessary here
    excludes.push(binding.arg as unknown as HTMLElement);
  }
  return function (mouseup, mousedown) {
    const popperRef = (
      binding.instance as ComponentPublicInstance<{
        popperRef: Nullable<HTMLElement>;
      }>
    ).popperRef;
    const mouseUpTarget = mouseup.target as Node;
    const mouseDownTarget = mousedown.target as Node;
    const isBound = !binding || !binding.instance;
    const isTargetExists = !mouseUpTarget || !mouseDownTarget;
    const isContainedByEl = el.contains(mouseUpTarget) || el.contains(mouseDownTarget);
    const isSelf = el === mouseUpTarget;

    const isTargetExcluded =
      (excludes.length && excludes.some((item) => item?.contains(mouseUpTarget))) ||
      (excludes.length && excludes.includes(mouseDownTarget as HTMLElement));
    const isContainedByPopper =
      popperRef && (popperRef.contains(mouseUpTarget) || popperRef.contains(mouseDownTarget));
    if (
      isBound ||
      isTargetExists ||
      isContainedByEl ||
      isSelf ||
      isTargetExcluded ||
      isContainedByPopper
    ) {
      return;
    }
    binding.value();
  };
}

const ClickOutsideDirective: ObjectDirective = {
  beforeMount(el, binding) {
    nodeList.set(el, {
      documentHandler: createDocumentHandler(el, binding),
      bindingFn: binding.value,
    });
  },
  updated(el, binding) {
    nodeList.set(el, {
      documentHandler: createDocumentHandler(el, binding),
      bindingFn: binding.value,
    });
  },
  unmounted(el) {
    nodeList.delete(el);
  },
};

export function setupClickOutsideDirective(app: App) {
  app.directive('click-outside', ClickOutsideDirective);
}

export default ClickOutsideDirective;

clickOutside.ts里面有一个isServer函数,去utils里面添加上

// /@/utils/is.ts
...
export const isServer = typeof window === 'undefined';
...

最后修改一下main.ts。

// main.ts
import './style/tailwind.scss';
import './assets/main.css';
import { createApp } from 'vue';
import App from './App.vue';

import { router, setupRouter } from './router';
import { setupStore } from '/@/stores';
import { initAppConfigStore } from './logics/initAppConfig';
import { registerGlobComp } from './components/registerGlobComp';
import { setupRouterGuard } from '/@/router/guard';
import { setupGlobDirectives } from '/@/directives';

async function bootstrap() {
  const app = createApp(App);

  // 配置 store
  setupStore(app);

  // 初始化内部系统配置
  initAppConfigStore();

  // 注册全局组件
  registerGlobComp(app);

  // 配置路由
  setupRouter(app);

  // 路由守卫
  setupRouterGuard(router);

  // 注册全局指令
  setupGlobDirectives(app);

  app.mount('#app');
}

bootstrap();

7、配置全局错误处理

新建/@/logics/error-handle/index.ts文件。

// /@/logics/error-handle/index.ts
/**
 * 用于配置全局错误处理功能,可以监控vue错误、脚本错误、静态资源错误和Promise错误
 */

import type { ErrorLogInfo } from '/#/store';

import { useErrorLogStoreWithOut } from '/@/stores/modules/errorLog';

import { ErrorTypeEnum } from '/@/enums/exceptionEnum';
import { App } from 'vue';
import projectSetting from '/@/settings/projectSetting';

/**
 * 处理错误堆栈信息
 * @param error
 */
function processStackMsg(error: Error) {
  if (!error.stack) {
    return '';
  }
  let stack = error.stack
    .replace(/\n/gi, '') // 删除换行符以保存传输内容的大小
    .replace(/\bat\b/gi, '@') // At in chrome, @ in ff
    .split('@') // 用@拆分信息
    .slice(0, 9) // 最大堆栈长度(Error.stackTraceLimit=10),因此只取前10个
    .map((v) => v.replace(/^\s*|\s*$/g, '')) // 删除多余的空格
    .join('~') // 手动添加分隔符以便稍后显示
    .replace(/\?[^:]+/gi, ''); // 删除js文件链接的冗余参数(?x=1等
  const msg = error.toString();
  if (stack.indexOf(msg) < 0) {
    stack = msg + '@' + stack;
  }
  return stack;
}

/**
 * 获取模块名称
 * @param vm
 */
function formatComponentName(vm: any) {
  if (vm.$root === vm) {
    return {
      name: 'root',
      path: 'root',
    };
  }

  const options = vm.$options as any;
  if (!options) {
    return {
      name: 'anonymous',
      path: 'anonymous',
    };
  }
  const name = options.name || options._componentTag;
  return {
    name: name,
    path: options.__file,
  };
}

/**
 * 配置Vue错误处理功能
 */

function vueErrorHandler(err: Error, vm: any, info: string) {
  const errorLogStore = useErrorLogStoreWithOut();
  const { name, path } = formatComponentName(vm);
  errorLogStore.addErrorLogInfo({
    type: ErrorTypeEnum.VUE,
    name,
    file: path,
    message: err.message,
    stack: processStackMsg(err),
    detail: info,
    url: window.location.href,
  });
}

/**
 * 配置脚本错误处理功能
 */
export function scriptErrorHandler(
  event: Event | string,
  source?: string,
  lineno?: number,
  colno?: number,
  error?: Error,
) {
  if (event === 'Script error.' && !source) {
    return false;
  }
  const errorInfo: Partial<ErrorLogInfo> = {};
  colno = colno || (window.event && (window.event as any).errorCharacter) || 0;
  errorInfo.message = event as string;
  if (error?.stack) {
    errorInfo.stack = error.stack;
  } else {
    errorInfo.stack = '';
  }
  const name = source ? source.substr(source.lastIndexOf('/') + 1) : 'script';
  const errorLogStore = useErrorLogStoreWithOut();
  errorLogStore.addErrorLogInfo({
    type: ErrorTypeEnum.SCRIPT,
    name: name,
    file: source as string,
    detail: 'lineno' + lineno,
    url: window.location.href,
    ...(errorInfo as Pick<ErrorLogInfo, 'message' | 'stack'>),
  });
  return true;
}

/**
 * 配置Promise错误处理功能
 */
function registerPromiseErrorHandler() {
  window.addEventListener(
    'unhandledrejection',
    function (event) {
      const errorLogStore = useErrorLogStoreWithOut();
      errorLogStore.addErrorLogInfo({
        type: ErrorTypeEnum.PROMISE,
        name: 'Promise Error!',
        file: 'none',
        detail: 'promise error!',
        url: window.location.href,
        stack: 'promise error!',
        message: event.reason,
      });
    },
    true,
  );
}

/**
 * 配置监控资源加载错误处理功能
 */
function registerResourceErrorHandler() {
  // 配置监控资源加载错误处理功能
  window.addEventListener(
    'error',
    function (e: Event) {
      const target = e.target ? e.target : (e.srcElement as any);
      const errorLogStore = useErrorLogStoreWithOut();
      errorLogStore.addErrorLogInfo({
        type: ErrorTypeEnum.RESOURCE,
        name: 'Resource Error!',
        file: (e.target || ({} as any)).currentSrc,
        detail: JSON.stringify({
          tagName: target.localName,
          html: target.outerHTML,
          type: e.type,
        }),
        url: window.location.href,
        stack: 'resource is not found',
        message: (e.target || ({} as any)).localName + ' is load error',
      });
    },
    true,
  );
}

/**
 * 配置全局错误处理
 * @param app
 */
export function setupErrorHandle(app: App) {
  const { useErrorHandle } = projectSetting;
  if (!useErrorHandle) {
    return;
  }
  // Vue异常监视;
  app.config.errorHandler = vueErrorHandler;

  // 脚本错误
  window.onerror = scriptErrorHandler;

  //  promise异常
  registerPromiseErrorHandler();

  // 静态资源异常
  registerResourceErrorHandler();
}

这部分数据会存储在store里面,所以需要在store新建errorLog模块,顺便在enums和types里补充所需类型。

// /@/stores/modules/errorLog.ts
import type { ErrorLogInfo } from '/#/store';

import { defineStore } from 'pinia';
import { store } from '/@/stores';

import { formatToDateTime } from '/@/utils/dateUtils';
import projectSetting from '/@/settings/projectSetting';

import { ErrorTypeEnum } from '/@/enums/exceptionEnum';

export interface ErrorLogState {
  errorLogInfoList: Nullable<ErrorLogInfo[]>;
  errorLogListCount: number;
}

export const useErrorLogStore = defineStore({
  id: 'app-error-log',
  state: (): ErrorLogState => ({
    // 异常列表
    errorLogInfoList: null,
    // 异常次数
    errorLogListCount: 0,
  }),
  getters: {
    getErrorLogInfoList(state): ErrorLogInfo[] {
      return state.errorLogInfoList || [];
    },
    getErrorLogListCount(state): number {
      return state.errorLogListCount;
    },
  },
  actions: {
    addErrorLogInfo(info: ErrorLogInfo) {
      const item = {
        ...info,
        time: formatToDateTime(new Date()),
      };
      this.errorLogInfoList = [item, ...(this.errorLogInfoList || [])];
      this.errorLogListCount += 1;
    },

    setErrorLogListCount(count: number): void {
      this.errorLogListCount = count;
    },

    /**
     * ajax请求错误后触发
     * @param error
     * @returns
     */
    addAjaxErrorInfo(error) {
      const { useErrorHandle } = projectSetting;
      if (!useErrorHandle) {
        return;
      }
      const errInfo: Partial<ErrorLogInfo> = {
        message: error.message,
        type: ErrorTypeEnum.AJAX,
      };
      if (error.response) {
        const {
          config: { url = '', data: params = '', method = 'get', headers = {} } = {},
          data = {},
        } = error.response;
        errInfo.url = url;
        errInfo.name = 'Ajax Error!';
        errInfo.file = '-';
        errInfo.stack = JSON.stringify(data);
        errInfo.detail = JSON.stringify({ params, method, headers });
      }
      this.addErrorLogInfo(errInfo as ErrorLogInfo);
    },
  },
});

// 需要在设置之外使用
export function useErrorLogStoreWithOut() {
  return useErrorLogStore(store);
}

新建/@/types/store.d.ts和/@/enums/exceptionEnum.ts文件。

// /@/enums/exceptionEnum.ts
/**
 * @description: 异常相关枚举
 */
export enum ErrorTypeEnum {
  VUE = 'vue',
  SCRIPT = 'script',
  RESOURCE = 'resource',
  AJAX = 'ajax',
  PROMISE = 'promise',
}

// /@/types/store.d.ts
import { ErrorTypeEnum } from '/@/enums/exceptionEnum';

// 错误日志信息
export interface ErrorLogInfo {
  // 错误类型
  type: ErrorTypeEnum;
  // 错误文件
  file: string;
  // 错误名称
  name?: string;
  // 错误消息
  message: string;
  // 错误堆栈
  stack?: string;
  // 错误详细信息
  detail: string;
  // 错误url
  url: string;
  // 错误时间
  time?: string;
}

修改main.ts。

// main.ts
import './style/tailwind.scss';
import './assets/main.css';
import { createApp } from 'vue';
import App from './App.vue';

import { router, setupRouter } from './router';
import { setupStore } from '/@/stores';
import { initAppConfigStore } from './logics/initAppConfig';
import { registerGlobComp } from './components/registerGlobComp';
import { setupRouterGuard } from '/@/router/guard';
import { setupGlobDirectives } from '/@/directives';
import { setupErrorHandle } from '/@/logics/error-handle';

async function bootstrap() {
  const app = createApp(App);

  // 配置 store
  setupStore(app);

  // 初始化内部系统配置
  initAppConfigStore();

  // 注册全局组件
  registerGlobComp(app);

  // 配置路由
  setupRouter(app);

  // 路由守卫
  setupRouterGuard(router);

  // 注册全局指令
  setupGlobDirectives(app);

  // 配置全局错误处理,useErrorHandle为false时不启用
  setupErrorHandle(app);

  app.mount('#app');
}

bootstrap();

注意全局错误处理是受ProjectConfig里的useErrorHandle变量控制,默认false。

结语

作为一个前端通用模板项目,这些配置只多不少,下一篇是axios封装。未完待续...