前言
之前介绍了 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 workspace
、 lerna
实现方式
在根目录下创建一个新的 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/ 目录 : 编辑器包的主要源代码。按功能划分为
components
和utils
等子目录。每个组件和工具都在一个单独的文件中,有助于代码的模块化和维护。 -
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)
}
}
设计分析
-
将编辑器模块从业务代码中解耦,通过 Vue 的插件机制,重新与项目建立起连接
-
插件的
options
可以从外部配置扩展编辑器的功能,如第三方依赖包,配合打包,无需在编辑器内另外打包 -
编辑器暴露配置和方法,如编辑器独有的选项配置、组件、指令可以进行暴露使用
数据管理方案
vuex
或 pinia
数据适用于项目集中数据管理,不适合使用在库的封装上
可以使用 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);
内存性能优化
-
静态数据,不使用响应式数据,考虑用
readonly
,shallowRef
、shallowReactive
-
避免频繁深拷贝数据,解析数据耗时需要分配新内存
-
优化逻辑,避免深度 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
发布