业务中如何抽离 npm 包,借助 Vite 打包库

前言

之前介绍了 Verdaccio 工具搭建 NPM 私有仓库,这次总结一下如何从业务抽离一个独立包,使用 Vite 工具打包发布上 npm 仓库

以项目中抽离编辑器包 为例,编辑器比较注重交互体验,样式设计和主题定制灵活,而在之前代码设计扩展性不好,数据管理混乱,代码冗余,不易阅读和维护,所以对项目和编辑器模块进行重构优化,抽离出一个 npm 包,方便做版本管理,复用在不同项目上,提高项目的维护性和开发效率。

问题分析

在优化前,分析目前项目存在的问题,抽离的包和项目的依赖关系和数据处理方式,然后根据问题的优先级和难易制定对应的优化策略。分析主要问题分为以下几个方面:

1、代码组织和结构不规范

  • 混乱的文件目录结构。文件和文件夹没有良好的按模块和功能分层结构,小驼峰、大驼峰、下划线混淆命名不规范,使得不易查找文件
  • 缺乏合理模块化。将不同功能的代码混杂在一起,导致模块之间耦合度高,文件行数过多,增加了代码的复杂度,修改一个模块会影响其他模块
  • 缺乏必要注释和说明,缩进和代码格式不统一,影响可读性

2、数据管理混乱

  • 滥用 vuex 数据管理和通信,同一个页面引入了多个模块的vuex数据,不易作为独立复用模块
  • 大量的重复数据,同一个数据在不同地方被重复定义,代码冗余,增加了数据的不一致性和修改的难度
  • 难以追踪数据流动,随意更改,难以理解一个数据是如何被改变和使用的

3、性能瓶颈

  • 滥用 watch 深度监听数据变化,占用内存,逻辑执行来源不清晰
  • 大量的事件监听函数,例如一棵大的 DOM 子元素都绑定注册事件,占用内存
  • 频繁修改响应式数据触发重新渲染,影响性能
  • 除此之外,还有随意的数据深度拷贝、频繁的dom操作、定时器不清除等等

代码优化不仅要在设计上做优化,提高代码的扩展性和维护性,同时也要考虑优化后的性能表现

优化方案设计

由于产品处于迭代中,不能停滞迭代来做代码的优化。所以采用渐进式的方式进行优化,在开发功能同时设计底层架构,逐步优化代码逻辑和结构,确保在优化过程中项目的稳定性和可用性,逐步抽离为一个独立的 npm

根据优先级和可执行性,先从整体架构再到局部优化,打好地基,大体分为以下步骤:

1、定义项目结构,合理划分目录和功能模块,确定整体项目结构

2、统一代码风格,使用 ESLint/Prettier/Styleint 做代码格式化,统一编码规范

3、删除编辑器的依赖和引入项目的组件,如独立包中使用的第三方包, @ 绝对路径引用项目的资源和方法

4、清理冗余代码:检查并清理不再需要的、与项目无关的代码

5、优化代码设计,如设计编辑器插件,扩展编辑器配置,暴露编辑器提供的的组件和接口方法

6、数据管理,替换 vuex 数据管理,设计编辑器内部数据统一化管理,设计组件通信方案

7、功能复用,减少冗余代码,抽象公共方法插件,如抽象 hooks

8、优化编辑器性能

9、抽离独立 npm 包,使用 vite 打包库

10、测试和编写文档

接下来主要讲一下设计实现上的核心步骤

优化步骤

目录结构优化

使用 Monorepo 方式进行代码管理,借鉴 pnpm workspacelerna 实现方式

在根目录下创建一个新的 packages 目录,这样也可以避免修改原来的代码逻辑,减少出bug 和代码冲突

YAML 复制代码
├── packages # 抽离包文件
│ ├── editor # 编辑器包
│ │ ├── assets # 资源文件
│ │ ├── src 编辑器内容
│ │ │ ├── plugins # 第三方库、插件
│ │ │ ├── lang # 语言包
│ │ │ ├── data # json文件
│ │ │ ├── hooks # 复用逻辑
│ │ │ ├── directives # 指令
│ │ │ ├── components # 组件
│ │ │ ├── utils # 工具方法
│ │ │ ├── layout # 布局组件
│ │ │ ├── fields # 编辑器控件组件
│ │ ├── constant.js # 常量配置文件
│ │ ├── README.md # 说明文件
│ │ ├── index.js 入口文件
│ │ ├── package.json

目录结构设计好处:

  • src/ 目录 : 编辑器包的主要源代码。按功能划分为 componentsutils 等子目录。每个组件和工具都在一个单独的文件中,有助于代码的模块化和维护。

  • components/ 目录: 存放编辑器的各个组件,按功能拆分组件,如工具栏、编辑区、侧边栏等。

  • hooks/ 目录: 存放 composition api 复用的逻辑方法,如拖拽、前进、后退历史管理等

  • fields/ 目录: 存放组件元素的接口和方法,如文本、图片、视频、轮播等组件。

  • index.js 文件: 作为包的入口文件,可以将所有组件和工具进行导出,方便其他项目引入和使用。

  • package.json 文件: 配置包的元信息、依赖项、版本等。

  • README.md 文件: 编写清晰的文档,包括安装、使用示例、API 说明等。快速上手和开发使用包。

编辑器插件设计

为了减少第三方依赖安装过多,打包库体积过大,可以利用Vue的插件机制扩展,减少依赖安装

将编辑器设计成插件安装,第三方依赖作为选项配置传进来,代码示例

js 复制代码
// 入口文件

// 导出组件使用
export { default as FocusOperator } from './src/components/com1'
// ...

// 导出方法
export * from './src/utils'
// ...

// 插件安装
export default {
    install (app, options) {
        const option = Object.assign(defaultInstallOpt, options || {})
        // 设置传入方法、插件、选项配置
        setConfig(option)

        app.config.globalProperties.$EDITOR = option
        // 注册全局组件
        app.component('componentName1', componentName1)
        app.component('componentName2', componentName2)
    }
}

设计分析

  1. 将编辑器模块从业务代码中解耦,通过 Vue 的插件机制,重新与项目建立起连接

  2. 插件的 options 可以从外部配置扩展编辑器的功能,如第三方依赖包,配合打包,无需在编辑器内另外打包

  3. 编辑器暴露配置和方法,如编辑器独有的选项配置、组件、指令可以进行暴露使用

数据管理方案

vuexpinia 数据适用于项目集中数据管理,不适合使用在库的封装上

可以使用 reactive 响应式 api 封装一个集中数据管理,通过 provie/inject 注入编辑器,设计修改数据的方法,知道修改数据来源

JS 复制代码
export const useEditorData = (obj) => {
  const state = reactive({
    // 定义集中数据状态
    data: {
    }
  })
  
  // 定义修改数据的接口
  const setStateData = (data) => {
    state.data = data
  }
  
  return {
    ...toRefs(state),
    setStateData
  }
}

编辑器组件通信方案

provide/inject 通信方案适用于单个数据的传递

$emit/$on 事件总线适用于发布订阅场景,比如购物车和商品购买列表

接下来介绍的另一种方案,是事件总线实现的延伸,利用闭包和回调函数,为不同的模块功能创建一个通信通道

js 复制代码
// 创建hook
function createHooks (isReactive) {
    const state = isReactive ? reactive({
        innerHandlers: []
    }) : ({
        innerHandlers: []
    })
    // 注册事件
    const use = (handler) => {
        state.innerHandlers = [...state.innerHandlers, handler]
        return () => eject(handler)
    }
    // 注销事件
    const eject = (handler) => {
        state.innerHandlers = state.innerHandlers.filter(i => i !== handler)
    }
    // 派发执行事件
    const exec = (arg, refresh) => {
        if (state.innerHandlers.length === 0) {
            return arg
        }
        let index = 0
        const innerHandlers = [...state.innerHandlers]
        let innerHandler = innerHandlers[index]
        while (innerHandler) {
            innerHandler(arg, refresh)
            index++
            innerHandler = innerHandlers[index]
        }
        return arg
    }
    return { use, eject, exec, state, getListeners: () => [...state.innerHandlers] }
}

createHooks 可以根据业务场景创建多个对象,use 注册一个事件,exec 遍历执行事件,eject 注销事件注册

例如编辑器组件拖拽

js 复制代码
// 创建钩子方法,统一管理
export function useVisualEditorHooks ({ state }) {
  const hooks = {
      // 拖拽开始动作
      onDragstart: createHooks(),
      // 拖拽结束动作
      onDragend: createHooks(),
      ...
  }
  return hooks
}

注册开始拖拽事件,在不同组件可以监听开始拖拽,然后接收拖拽的数据,执行它的回调函数

JS 复制代码
// component1.vue
hooks.onDragstart.use((data) => {
// 处理开始拖拽逻辑
})

// component2.vue
hooks.onDragstart.use((data) => {
// 处理开始拖拽逻辑
})

在编辑器开始拖拽,派发事件

js 复制代码
dragstart function({ component, event }) => {
    ...
    // 派发事件
    const exitStart = hooks.onDragstart.exec(component, event)
    
    // 在需要地方执行注销释放资源
    exitStart()
}

性能优化

拖拽事件优化

使用 data- 属性和事件冒泡来优化拖拽,减少嵌套 DOM 节点绑定事件,统一在最外层处理拖拽逻辑

1、使用 data- 属性存储拖拽相关信息

html 复制代码
<div 
   id="draggableElement" 
   data-draggable="true" 
   data-element-id="1" 
   data-type="text"
>文本</div>

2、使用事件冒泡监听拖拽事件

在拖拽容器或父级元素上监听拖拽事件,利用事件冒泡机制捕获拖拽事件,而不是在每个拖拽元素上分别添加事件监听器

JS 复制代码
function handleDragStart(event) {
  if (event.target.dataset.draggable === 'true') {
    const elementId = event.target.dataset.elementId;
    // 获取拖拽元素的初始位置等信息
  }
}

function handleDrag(event) {
  // 处理拖拽逻辑,根据鼠标位置更新拖拽元素的位置
}

function handleDragEnd(event) {
  // 完成拖拽,执行最终操作,如更新数据或执行其他逻辑
}

数据遍历优化

将树形结构进行扁平化处理,深层数据根据唯一 ID 创建数据映射,查询数据减少数据遍历的次数,从而提高数据访问和操作的效率

遍历数据并构建映射,添加父级 id parentId 知道层级关系

JS 复制代码
const dataMap = new Map();

function buildDataMap(data, parentId) {
  for (const item of data) {
    // 将数据项存储到映射中,使用 ID 作为键
    dataMap[item.id] = Object.assign(item, { parentId })

    // 如果数据项包含嵌套子数据,递归处理子数据
    if (item.children && item.children.length > 0) {
      buildDataMap(item.children, item.id);
    }
  }
}

// 调用构建映射的函数,并传入深层数据
buildDataMap(deepData);

这样就可以直接根据 id 查找到想要的数据,无需深度递归遍历

JS 复制代码
// 通过 ID 直接访问数据
const item = dataMap.get(desiredItemId);

内存性能优化

  • 静态数据,不使用响应式数据,考虑用 readonlyshallowRefshallowReactive

  • 避免频繁深拷贝数据,解析数据耗时需要分配新内存

  • 优化逻辑,避免深度 watch 监听大对象,占用内存

  • 注册事件、定时器、DOM引用,在组件销毁及时清除

Vite 打包库

Vite 打包库,使用 lib 模式

  • entry:指定要打包的入口文件。

  • name:包的名称

  • fileName:包文件的名称,默认是umd和es两个文件

js 复制代码
export default defineConfig({
  build: {
    lib: {
      entry: 'src/packages/index.ts', // 你的入口文件路径
      name: 'vite-lib', // 你的库名称
      fileName: (format) => `vite-lib.${format}.js` // 打包后的文件名
    }
  }
}

如果项目引用了第三方插件,那么需要在这里设置排除,如果不设置的话,第三方插件的源码也会被打包进来,这样打包文件就变大了。

配置 rollupOptions , external 排除打包依赖的包

css 复制代码
rollupOptions: {
  // 此处添加外部依赖项(如 Vue),以避免将其打包进你的库中
  external: [
    'vue',
    'element-plus',
    'dayjs',
    'lodash'
  ],
  output: {
  // 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
  globals: {
      vue: 'Vue'
    }
  }
}

完整的 Vite 配置文件

JS 复制代码
import {
    defineConfig,
    normalizePath
} from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import path from 'path'

// 用 normalizePath 解决 window 下的路径问题
const variablePath = normalizePath(path.resolve('./src/assets/scss/variables.scss'))

export default defineConfig({
    plugins: [
        vue(),
        vueJsx()
    ],
    // css 相关的配置
    css: {
        preprocessorOptions: {
            scss: {
                // additionalData 的内容会在每个 scss 文件的开头自动注入
                additionalData: `@import "${variablePath}";`
            }
        }
    },
    build: {
        lib: {
            entry: 'src/packages/index.ts', // 你的入口文件路径
            name: 'vite-lib', // 你的库名称
            fileName: (format) => `vite-lib.${format}.js` // 打包后的文件名
        },
        sourcemap: true, // 输出.map文件
        rollupOptions: {
            // 此处添加外部依赖项(如 Vue),以避免将其打包进你的库中
            external: [
                'vue',
                'element-plus',
                'dayjs',
                'lodash'
            ],
            output: {
            // 设置为 'es' 或 'cjs',取决于你的库的使用场景
                // format: 'es',
                // exports: 'named',
                // 在 UMD 构建模式下为这些外部化的依赖提供一个全局变量
                globals: {
                    vue: 'Vue'
                }
            }
        }
    }
})

配置 package.json 发布

配置 files 将要发布上 npm 目录填写上去,设置要引入这个的包的地址

JSON 复制代码
{
    "name": "vite-lib", // lib包的名字 // 必须
    "version": "0.1.0", // 版本号 // 必须
    "description": "", //描述
    "main": "./dist/vite-lib.umd.js",  // commonjs 入口文件地址
    "module": "./dist/vite-lib.es.js",  // ES 入口文件地址
    "files": [
      "dist"
    ],
    "exports": { 
        ".": { 
            "import": "./dist/vite-lib.es.js",
            "require": "./dist/vite-lib.umd.js"
        } 
    },
    "private": false,
    "dependencies": {   // 依赖
        "vue": "^3.2.16" 
    }, 
    "devDependencies": { "
      "@vitejs/plugin-vue": "^4.2.3",
      "@vitejs/plugin-vue-jsx": "^3.0.1",
      "sass": "^1.63.6",
      "vite": "^4.4.3"
    }
}

发布 npm 包

1、登录 npm 账号

2、修改版本号,执行 npm publish 发布

相关推荐
寒山李白17 分钟前
VuePress搭建文档网站/个人博客(详细配置)主题配置-侧边栏配置
前端·vue.js·vue·博客·vuepress·网站
二十雨辰35 分钟前
[uni-app]小兔鲜-01项目起步
前端·javascript·vue.js·uni-app
kkkAloha39 分钟前
面经 | JS
开发语言·前端·javascript
芥子沫1 小时前
Safari-常用快捷键(IPadOS版本)
前端·safari
晓风残月Yuperman1 小时前
领域驱动DDD三种架构-分层架构、洋葱架构、六边形架构
架构
Amd7941 小时前
使用 Nuxt Kit 的构建器 API 来扩展配置
webpack·vite·前端开发·插件·nuxt kit·扩展配置·构建器 api
wgc891781 小时前
Zabbix短信告警示例
前端·chrome·zabbix
逝缘~2 小时前
uni-icons自定义图标详细步骤及踩坑经历
前端·javascript·css·vue.js·uni-app·html
Shinobi_Jack2 小时前
Go调试工具—— Delve
前端·后端·go
QGC二次开发2 小时前
Vue3:快速生成模板代码
前端·javascript·vue.js·前端框架·vue