前言
生命不息,折腾不止。我很喜欢那些搞事情的人,比如说创业者,发明者。从大处说,他们不是解决了我们的就业,就是推动了社会的进步。从小处说,生活需要目标和想法,不然就会像一杯白开水一样,索然无味。最近萌生了想开发一个列表项固定高度虚拟滚动工具库的想法。对于项目中高频使用的库,若有时间和精力的话,最好自己实现一下。这样做至少有两个好处,第一,做定制化业务开发很容易,第二,工具出现问题很快能定位到症结之所在。顺着这个思路,趁着热乎劲在,说干就干。
动手实现
提前说一下,你可以点击 这里 查看本文开发的虚拟列表npm包的使用方法。现在讲一下这个npm包的开发过程。
项目初始化
用脚手架生成一个vue3+vite项目, 下面四种命令,都能生成一个vue3+vite4项目。
npm init vite
# 等价于
npm create vite
# 使用yarn
yarn create vite
# 使用pnpm
pnpm create vite
本文使用的是pnpm,因为pnpm安装工具包既快,也节省空间。选择 vue3 + ts 模式,
pnpm init vite vue3-virtual-list
同时添加声明文件 vite-env.d.ts ,处理引入 .vue 文件编码软件标示红色波浪线问题,如下所示:
vite-env.d.ts文件类型定义如下:
/// <reference types="vite/client" />
declare module '*.vue' {
import { defineComponent } from 'vue';
const Component: ReturnType<typeof defineComponent>;
export default Component;
}
需要确保包含在 tsconfig.json 的include中,包含 vite-env.d.ts文件路径。
{
// tsconfig.json配置
"include": ["./**/*.ts", "./**/*.d.ts", "./**/*.tsx", "./**/*.vue"],
}
开发功能
我们开发一个每项固定高度的虚拟滚动列表组件,要想使外层容器可以滚动,需要满足两个条件:
- 外层容器的滚动属性要设置为
overflow:auto
- 内层容器的高度要大于外层容器
vue3 支持用状态驱动动态样式值,可以在style标签中使用v-bind绑定动态值。我们把只需要设置一次的动态样式值,全部提取到style部分,用v-bind设置。需要注意的是,v-bind里面写的js表达式必须使用单引号括起来。
它的原理也很简单,给css属性设置了一个var变量值, 如下图所示:
通过修改这个var样式变量,动态修改样式属性值。现在知道为什么js表达式要用单引号括起来,因为css的var变量属性名必须是字符串。
<style lang="less">
// 外层容器
.virtual-list-scroll-box {
position: relative;
width: v-bind('`${props.width}px`');
height: v-bind('`${props.height}px`');
overflow: auto;
border: 1px solid #ccc;
cursor: default;
&::-webkit-scrollbar {
display: none;
}
// 内容容器(高度大于外层容器,外层容器才能滚动)
.virtual-list-content-box {
height: v-bind('`${props.itemHeight * props.itemCount}px`');
.list-item {
position: absolute;
width: 100%;
height: v-bind('`${props.itemHeight}px`');
}
}
}
</style>
<template>
<div class="virtual-list-scroll-box" @scroll.passive="handleScroll">
<div class="virtual-list-content-box">
<div v-for="(item, index) in visibleListData" :style="item.style" :key="index" class="list-item">
<slot name="listItem" :itemData="item.itemData" :index="index"></slot>
</div>
</div>
</div>
</template>
实现虚拟列表的关键逻辑是容器滚动后变更可见区域的显示条目,具体步骤如下:
- 根据容器高度和每个数据项的高度计算可视区域可以显示的元素数量
- 根据容器的垂直方向偏移计算可视区域滚动数据的起始索引,需要加上顶部缓冲量
- 根据前面计算的起始索引计算可视区域滚动数据的结束索引,需要加上底部缓冲量
- 计算从滚动起始索引到结束索引之间对应每个属性项在滚动容器垂直方向上的偏移位置,设置到列表数据项上,最后进行列表数据渲染
Vue版本的代码实现如下:
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue';
const props = withDefaults(
defineProps<{
// 数据列表
data: any[];
// 滚动容器的高度
height: number;
// 滚动容器的宽度
width: number;
// 每个数据项的高度
itemHeight: number;
// 数据项的数量
itemCount: number;
}>(),
{
data: () => [],
height: 500,
width: 200,
itemHeight: 50,
itemCount: 10,
}
);
// 记录容器滚动卷去的高度
const scrollOffset = ref(0);
// 实时监听滚动距离
const handleScroll = (evt: UIEvent) => {
scrollOffset.value = (evt.currentTarget as HTMLDivElement).scrollTop;
};
// 上下缓冲区数据项偏移量
const BUFF_OFFSET = 2;
// 可视区能展示的元素的最大个数(向上取整)
const VISIBLE_COUNT = Math.ceil(props.height / props.itemHeight);
// 可视区域列表数据
const visibleListData = computed(() => {
const { data, itemHeight, itemCount } = props;
// 可视区起始索引(向下取整)
const startIndex = Math.floor(scrollOffset.value / itemHeight);
// 顶部缓冲区起始索引(向上的时候索引号减去两个偏移量,最小偏移量是0)
const startBuffIndex = Math.max(0, startIndex - BUFF_OFFSET);
// 底部缓冲区结束索引(向下的时候索引号加上两个偏移量,最大索引号是数量总数量-1)
const endBuffIndex = Math.min(startIndex + VISIBLE_COUNT + BUFF_OFFSET, itemCount - 1);
return data.slice(startBuffIndex, endBuffIndex + 1).map((itemData, index) => {
const offsetY = startBuffIndex + index;
return {
itemData,
// 计算每个元素在滚动容器中的垂直偏移量
style: { transform: `translateY(${itemHeight * offsetY}px)` },
};
});
});
// watchEffect(() => {
// console.log(visibleListData.value, props.data);
// });
</script>
这里有一点要说明一下: 动画性能 css的translateY > js的translateY > js的top
,想要具体了解请参考此文 。所以设置列表的滚动偏移量, 使用了transform:translateY(offset)
, 而不是top:offset
。笔者还发现,有一处地方可以优化,就是给数据项添加偏移属性,可以加个判断条件,已经添加过的偏移量的元素,就没有必要重复添加偏移量了。
打包构建
执行构建命令后,发现构建出来的组件包,虽然对组件内部的样式进行了分离,可是没有加载组件内部定义的样式。如下图所示,组件样式被分离到style.css,可是bundle.js中并没有引入style.css文件。
对使用组件的人来说,使用组件时,还得手动引入组件的组件,不太友好。在网上查了查,找到一个解决方案,需要引入vite的一个插件vite-plugin-style-inject,在vite.config.ts中做如下配置:
// vite.config.js
import { defineConfig } from 'vite';
import VitePluginStyleInject from 'vite-plugin-style-inject';
export default defineConfig({
plugins: [VitePluginStyleInject()],
})
再次打包,可以看到,样式已经引入进来了。顺便说一句,感觉这篇文章写得不错,发现问题,解决问题,过程描述的很详实,也有一定的深度,可是看阅读量及点赞数,明显属于被埋没的好文。在此顶一下这个作者。
发布npm包
- 在 npm 官网 注册一个账号
- 发布npm包时,要将自己配置的别的npm镜像源切换到npm官方镜像源
nrm ls
# 切换镜像源
nrm use npm
- 在终端登陆账号
npm login
npm notice Log in on https://registry.npmjs.org/
Username: // 用户名
Password: // 密码(只能手输,不能复制粘贴)
Email: (this IS public) // 注册邮箱
Enter one-time password from your authenticator app: // 注册邮箱收到的EOTP code(当次有效)
- 发布
执行npm publish
,看下面的错误提示,猜测是与别人发布的包重名了
去npm官网查了一下,果不其然
改个名字,重新发布,这次看到发布成功了
- 更新
// patch--补丁号,修复bug,小变动,如 v1.0.0->v1.0.1
npm version patch
// minor--次版本号,增加与修改功能,如 v1.0.0->v1.1.0
npm version minor
// major--主版本号,不兼容的修改,如 v1.0.0->v2.0.0
npm version major
用法示例
- 安装npm包
pnpm add vue3-virtual-list-comps
- 在页面中引用 这个组件有5个配置参数
组件属性名 | 含义 |
---|---|
data | 列表数据 |
height | 滚动容器的高度,单位px |
width | 滚动容器的高度,单位px |
itemHeight | 每个数据项盒子的高度,单位px |
itemCount | 数据项的数量 |
<template>
<FixedSizeList :data="data" :height="200" :width="50" :itemHeight="20" :itemCount="data.length">
<template #listItem="{ itemData, index }">
<div :class="index % 2 === 0 ? 'even' : 'odd'">{{ itemData }}</div>
</template>
</FixedSizeList>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { FixedSizeList } from 'vue3-virtual-list-comps';
const data = ref([...new Array(101).keys()].slice(1, 101));
// console.log(data.value.length);
</script>
<style scoped>
.odd {
background-color: aliceblue;
}
.even {
background-color: lightgreen;
}
</style>
- 效果演示 可以看到,无论如何滚动,可视区域最多渲染14条数据记录,不会引起页面的卡顿。
Math.ceil(props.height / props.itemHeight) + BUFF_OFFSET(顶部+底部)= 200/20 + 2+2 =14
开发模式改进
将组件代码和页面调试代码放在同一个工程中,每次部署的时候, 得手动进行拆分,把demo演示示例功能移除,再进行打包发布。感觉很是不便。之前看到过pnpm支持workspace功能,可以把一个项目按照功能拆分成多个子项目,每个子项目可以把别的子项目当做依赖包引用,每个子项目可以单独运行,单独打包。非常适合组件库开发场景,是我们所需要的。
我们把项目拆分成两个子项目。用pnpm init vite 项目名
命令在根目录下创建两个子项目。组件的实现放在core子项目,组件的调试放在demo子项目。
新建一个 pnpm-workspace.yaml 文件,配置内容如下:
packages:
- "core/**"
- "demo/**"
接下来给每个子项目安装依赖包。pnpm的workspace依赖包的安装分两种方式,一种是在根目录下安装,安装的依赖包所有子项目共享,另外一种是给每个子项目独自安装依赖包。特别要说明的是,一个子项目可以把另外一个子项目当做依赖包,进行安装使用。不同方式的依赖包安装命令如下:
# 安装公共npm依赖包 -w 安装在workspace空间根目录下, [-D]可选,指示是开发时依赖包
pnpm i typescript -w [-D]
# 给某个项目安装npm依赖包 -r是递归安装,这个包依赖了别的包,也会被安装
pnpm add 包名 -r --filter 子项目名称
# 把A子项目当做依赖包,安装到B,C项目
pnpm i A -r --filter B C
此外,你会发现,把A子项目当做依赖包,安装到B子项目,B子项目中package.json记录的信息如下:
"dependencies": {
"@A": "workspace:^1.0.0"
},
相信你也和我一样心中有疑问:当这样的工具包被发布后,外网如何找到形如"@A": "workspace:^1.0.0"
这样的依赖包。实际上,当执行了pnpm publish
后,pnpm会把基于workspace的依赖变成外部依赖,如:
// 执行pnpm publish之前
"dependencies": {
"@A": "workspace:^1.0.0"
},
// 执行pnpm publish之后
"dependencies": {
"@A": "^1.0.0"
},
另外,还要说一下pnpm的workspace模式,每个子项目的启动/打包命令是:
pnpm -C 子项目路径 子项目的package.json中的scripts配置的命令
举例:pnpm -C ./demo start
, 学完这些知识,相信你对拆分子项目,给拆分之后的子项目安装依赖,如何在根目录下运行拆分后的子项目,已经悉数掌握了,如果你还不会,可以下载文末的代码。剩下的就是写每个子项目的业务逻辑,以及在package.json中定义项目信息,配置子项目下的vite.config.ts文件。不难的,这里就不展开了。
发布改进
组件发布之后,免不了增加/修改功能或者修复缺陷。如果每次都手动去维护工具包的发布记录的话,比较低效。我们可以用release-it工具,自动生成更新记录,升级版本号,并发布到npm官网。
1. 局部安装release-it
pnpm add release-it -w -D
2. 创建和配置.release-it.json
下面的配置含义是,执行release-it之后,将代码提交到github代码仓库,提交信息为release: v{version}",将组件包发布到npm仓库,changelog文件创建成功后,输出"更新版本成功",
@release-it/conventional-changelog
包的配置项含义是:infile配置的是changelog文件的名称,preset配置的是生成changelog的内容风格。
{
"github": {
"release": true
},
"git": {
"commitMessage": "release: v${version}"
},
"npm": {
"publish": true
},
"hooks": {
"after:bump": "更新版本成功"
},
"plugins": {
"@release-it/conventional-changelog": {
"preset": "angular",
"infile": "CHANGELOG.md"
}
}
}
3. 配置scripts命令并运行
"scripts": {
"release": "release-it"
},
执行pnpm release
命令之后, 可以看到安装Angular git提交日志规范,生成了变更日志文件。并会询问是否提交changelog和package.json文件改动到本地代码仓库,是否打标签,是否推送到远程仓库。
结语
本文的代码已经分享到码云,你可以点击这里下载学习。本以为会把时间消耗在学习npm包的发布流程方面,实际开发下来,发现在pnpm+workspace这一块花费的时间最多,所以自认为是难点的地方,可能并不见得是难点。只有自己动手做一下,自己薄弱的环节才会暴露出来。在还用不上的时候提前暴露,总比事到临头,带着压力去攻克难关,在心态和开发体验上,人感觉要好很多,这就是爱折腾的意义之所在。