@darkchest/wck
v0.0.16
Published
@darkchest/wck是一个通过将 Vue 单文件组件 (SFC) 转换为通用的web-component组件的解决方案。
Readme
@darkchest/wck
🌉 Vue 单文件组件 (SFC) 转web-component组件
@darkchest/wck (全称@darkchest/WebComponentKit)是一个通过在vite中作为插件编译Vue单文件组件 (SFC) 为通用的web-component组件的解决方案。
前言
总会有一些场景, 我们无法使用现代化的框架系统.
在习惯了现代化数据驱动的开发模式下再次回到操作dom来更新UI的模式(没有了事件, scss, 模板, 数据监听等), 的确让人感到非常不便. 并且因为全局作用域的关系, 我们很容易碰上js变量, css样式的冲突导致的预期之外的bug. 更不幸的是由于没有固定的模板结构, 导致我们的静态网页代码有很大概率变得臃肿且越来越难以维护.
所以, 如果能够以vue2的options格式去编写web-component组件, 在开发阶段借助vue的ide插件帮助我们进行代码提示及错误警告. 在应用阶段又以web-component组件的方式去使用, 将vue组件内部的逻辑和样式封装在web-component组件内, web-component组件内部负责抹平与vue组件的差异并且对外只暴露出vue组件中定义的props, methods, events, slots等, 这就足够应对大部分的需求了. 这不就是应对困境的最好方案吗?
这也是我开发@darkchest/wck的初衷!
实际上在这之前我开发过几个版本的解决方案:
通过vue3官方提供的方案构建web-component组件, 最终因为一并将vue3的标准版代码打包到web-component组件中导致整个组件体积巨形膨胀(一个小组件体积上涨到90kb+), 考虑到既然都直接打包了vue3代码, 那么构建web-component组件好像没什么意义(还不如直接使用vue3进行开发), 此方案废弃.
通过lit来封装web-component组件, 整体来说是很顺利的. 但是在开发过程中, 由于要使用装饰器(不太熟悉ts), 且要自行实现render更新html, 整个开发过程不太舒适并且没有代码提示, 最终打包后体积大约为30kb+, 此方案搁置.
通过官方提供的@vue/compiler-sfc解析vue单文件组件 (SFC), 并将其script部分进行转换成标准的web-component组件. 逻辑部分已完成, 但在解析template部分时出现重大问题, 因为template部分解析出来属于vue特有的抽象语法树, 手上又没有详尽的资料又不想去啃源码, 导致template部分无法正确转换, 此方案被废弃.
最终, 我找到了petite-vue, 根据网络描述, 它是由 Vue.js 团队推出的重量仅约6KB的小型Vue版本,专为网页上的渐进式增强设计。它保留了Vue的核心模板语法与响应式机制,但特别优化用于在已有的HTML页面上增添少量交互效果. 经过测试它无需经过编译即可直接支持template语法(v-for, v-if, v-model, v-html, @click等), 允许直接在dom上应用template语法且无编译直接生效. 于是, 将方案3中的script转换与petite-vue的模板语法合并, 再抹平petite-vue语法与标准web-component的差异后的最终方案终于完成(与标准版vue仍有差距, 但是核心功能都已实现, 用于开发web-component组件应该是足够了), 最小体积降至16kb(未进行gzip压缩).
祝好!~
✨ 核心特性
- ✅ 支持props属性(注意vue中允许使用大驼峰和小驼峰定义属性, 但是web-component组件属性只允许小写, 所以vue中定义的属性在编译成web-component组件时时会转成小写和中划线连字符形式的属性并进行映射)
- ✅ 支持$el, $parent, $children, $root属性
- ✅ 支持$emit事件
- ✅ 支持methods方法
- ✅ 支持compputed属性
- ✅ 支持watch属性
- ✅ 支持slot匿名和具名插槽
- ✅ 支持mounted/onMounted, destroyed/unmounted/onUnmounted生命周期
- ✅ 支持scss样式
关于props大小写问题
由于vue中定义props是允许大小写字母的.而web-component组件的attributes只允许小写字母(包含中划线), 所以当我们在vue组件中定义一个带有大写字母的属性时(例如appTitle), 在@darkchest/wck插件将它转成web-component组件后, 插件会自动在web-component组件中声明apptitle, app-title两种形式(纯小写形式和小写中划线形式)的属性来映射appTitle这个vue组件属性, 所以当我们使用web-component组件时, 可以直接在html上使用全小写的属性名来设置组件的默认属性值:
<todo-list id="demo1" apptitle="*我的清单*"></todo-list>
<todo-list id="demo2" app-title="*我的清单*"></todo-list>
<!-- 以上两种写法都可以设置vue组件的appTitle属性默认值 -->
<script type="module">
var todo1 = document.querySelector('#demo1');
todo1.apptitle="我的清单1"; // 设置有效
// todo1['app-title'] = "我的清单1"; // 设置有效
var todo2 = document.querySelector('#demo1');
todo2.apptitle="我的清单2"; // 设置有效
// todo2['app-title'] = "我的清单2"; // 设置有效
</script>关于属性, 方法, 事件, 生命周期钩子
- 在vue组件中props和methods定义的属性和方法, 都会直接通过web-component组件的属性和方法暴露给页面.
- 在vue组件中支持(onMounted/mounted)和(onUnmounted/destroyed)生命周期钩子函数来执行初始化与销毁操作.
- 所有编译后的web-component组件都会默认触发mounted/unmounted两个生命周期事件方便页面监听并进行某些初始化操作.
- 所有编译后的web-component组件都默认增加$el, $parent, $children, $root属性.
- 所有编译后的web-component组件都默认增加$emit(name, data), on(name, handler), once(name, handler), off(name, handler)方法.
🛠️ 安装与配置
1. 使用示例(重要: 请参考TodoList.vue文件示例, 该文件中已包含所有核心功能并且开箱即用)
// 创建一个项目文件夹, 例如helloworld, 然后在文件夹中执行npm init -y初始化一下(命令执行完会自动生成package.json文件).
// 然后我们在文件夹中手动创建:
// - vite.config.js
// - index.html
// - src/index.js
// - src/components/TodoList.vue
// 以上文件的内容直接复制下面对应的内容即可.
// (注意package.json不能复制, 而是手动修改scripts命令的部分, 具体请看下面的package.json部分)2. 安装依赖(在刚刚创建的helloworld文件夹中执行以下命令安装依赖)
npm install @darkchest/wck vite -D3. 在package.json中手动配置命令
// package.json
// 主要是增加dev/build俩个命令来启动vite开发和打包.
{
...,
"scripts": {
"dev": "vite",
"build": "vite build"
},
...,
}4. 复制下面的文件内容到项目目录对应的文件中
// vite.config.js
import { defineConfig } from 'vite';
import path from 'path';
import wck from '@darkchest/wck';
export default defineConfig({
plugins: [
wck(), // 设置转换插件
],
build: {
lib: {
entry: path.resolve(__dirname, 'src/index.js'),
name: 'MyLib', // 在iife和umd模式下必填
formats: ['es', 'iife', 'umd'],
fileName: (format, name) => `${name}.${format}.min.js`
},
}
})// src/index.js
import TodoList from './components/TodoList.vue';
customElements.define('todo-list', TodoList);
// 注意: web-component组件名必须为小写+中划线命名(有且至少一个中划线), 否则无法成功注册.// src/components/TodoList.vue
<template>
<div id="app">
<div class="todo-container">
<!-- Header Slot -->
<header>
<slot name="header">
<h1 class="app-title">{{ appTitle }}</h1>
<p class="app-description">使用 Vue2 实现的 Todo List 应用</p>
</slot>
</header>
<!-- Add Todo Form -->
<div class="add-todo">
<input
type="text"
v-model="newTodo"
placeholder="请输入待办事项..."
@keyup.enter="addTodo"
class="todo-input"
/>
<button @click="addTodo" class="add-btn">添加</button>
<button @click="clearCompleted" class="clear-btn">清除已完成</button>
</div>
<!-- Filter Controls -->
<div class="filter-controls">
<button
@click="filter = 'all'"
:class="{ active: filter === 'all' }"
class="filter-btn"
>
全部 ({{ totalTodos }})
</button>
<button
@click="filter = 'active'"
:class="{ active: filter === 'active' }"
class="filter-btn"
>
未完成 ({{ activeTodosCount }})
</button>
<button
@click="filter = 'completed'"
:class="{ active: filter === 'completed' }"
class="filter-btn"
>
已完成 ({{ completedTodosCount }})
</button>
</div>
<!-- Todo List -->
<div class="todo-list">
<div v-if="filteredTodos.length === 0" class="empty-state">
<p v-if="todos.length === 0">暂无待办事项,请添加一个吧!</p>
<p v-else>没有{{ filter === 'active' ? '未完成' : '已完成' }}的事项</p>
</div>
<div
v-for="todo in filteredTodos"
:key="todo.id"
class="todo-item"
:class="{ completed: todo.completed }"
>
<div class="todo-content">
<input
type="checkbox"
v-model="todo.completed"
class="todo-checkbox"
:id="'todo-' + todo.id"
/>
<label :for="'todo-' + todo.id" class="todo-text">
<span>{{ todo.text }}</span>
<span class="todo-date">{{ formatDate(todo.createdAt) }}</span>
</label>
</div>
<div class="todo-actions">
<button @click="editTodo(todo)" class="edit-btn">编辑</button>
<button @click="deleteTodo(todo.id)" class="delete-btn">删除</button>
</div>
</div>
</div>
<!-- Stats -->
<div class="stats">
<p v-if="todos.length > 0">
已完成 {{ completedTodosCount }} / 总共 {{ totalTodos }} 个待办事项
<span v-if="hasCompletedTodos" class="completion-rate">
(完成率: {{ completionRate }}%)
</span>
</p>
<!-- Progress Bar Slot -->
<slot name="progress">
<div class="progress-container">
<div class="progress-bar" :style="{ width: completionRate + '%' }"></div>
</div>
</slot>
</div>
<!-- Footer Slot -->
<footer>
<slot name="footer">
<p class="footer-text">双击事项可标记为完成/未完成</p>
<p class="footer-text">使用 Vue2 实现 - 包含 Props, Data, Methods, Slots, Watch, Computed, 生命周期</p>
</slot>
</footer>
</div>
<!-- Edit Modal -->
<div v-if="editingTodo" class="modal-overlay" @click="cancelEdit">
<div class="modal" @click.stop>
<h3>编辑待办事项</h3>
<input
type="text"
v-model="editingTodo.text"
@keyup.enter="saveEdit"
class="edit-input"
/>
<div class="modal-actions">
<button @click="saveEdit" class="save-btn">保存</button>
<button @click="cancelEdit" class="cancel-btn">取消</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'TodoApp',
// Props 示例
props: {
initialTodos: {
type: [Array, Object],
default: () => []
},
appTitle: {
type: String,
default: '我的待办清单'
},
enableLocalStorage: {
type: Boolean,
default: true
}
},
// Data 示例
data() {
return {
newTodo: '',
todos: [],
filter: 'all',
editingTodo: null,
lastTodoId: 0
};
},
// Computed 示例
computed: {
// 计算过滤后的待办事项
filteredTodos() {
switch (this.filter) {
case 'active':
return this.todos.filter(todo => !todo.completed);
case 'completed':
return this.todos.filter(todo => todo.completed);
default:
return this.todos;
}
},
// 计算未完成事项数量
activeTodosCount() {
return this.todos.filter(todo => !todo.completed).length;
},
// 计算已完成事项数量
completedTodosCount() {
return this.todos.filter(todo => todo.completed).length;
},
// 计算总事项数量
totalTodos() {
return this.todos.length;
},
// 计算完成率
completionRate() {
if (this.totalTodos === 0) return 0;
return Math.round((this.completedTodosCount / this.totalTodos) * 100);
},
// 检查是否有已完成事项
hasCompletedTodos() {
return this.completedTodosCount > 0;
}
},
// Watch 示例
watch: {
// 监听 todos 变化并保存到 localStorage
todos: {
handler(newTodos, oldTodos) {
if (this.enableLocalStorage) {
localStorage.setItem('vue-todos', JSON.stringify(newTodos));
}
// 发送事件给父组件(如果需要)
this.$emit('todos-updated', newTodos);
},
deep: true
},
// 监听 filter 变化
filter(newFilter, oldFilter) {
console.log('过滤器已更改为: '+newFilter);
}
},
// Methods 示例
methods: {
// 添加新的待办事项
addTodo() {
if (this.newTodo.trim() === '') return;
this.todos.push({
id: ++this.lastTodoId,
text: this.newTodo.trim(),
completed: false,
createdAt: new Date()
});
this.newTodo = '';
console.log('已添加新的待办事项');
},
// 删除待办事项
deleteTodo(id) {
const index = this.todos.findIndex(todo => todo.id === id);
if (index !== -1) {
this.todos.splice(index, 1);
console.log('已删除待办事项 ID: '+id);
}
},
// 编辑待办事项
editTodo(todo) {
this.editingTodo = { ...todo };
},
// 保存编辑
saveEdit() {
if (this.editingTodo && this.editingTodo.text.trim() !== '') {
const index = this.todos.findIndex(todo => todo.id === this.editingTodo.id);
if (index !== -1) {
this.$set(this.todos, index, { ...this.editingTodo });
console.log('已更新待办事项 ID: '+this.editingTodo.id);
}
}
this.editingTodo = null;
// 已知这里会报错, 这是petite-vue内部的bug, 因为并不影响使用, 所以我不打算修复(没有petite-vue源码).
// 感觉这是因为v-if的优先级没有v-model高导致的, 所以我们可以通过额外设置一个开关变量来绕开这个报错, 只要不设置对象为null就行.
},
// 取消编辑
cancelEdit() {
this.editingTodo = null;
// 已知这里会报错, 这是petite-vue内部的bug, 因为并不影响使用, 所以我不打算修复(没有petite-vue源码).
// 感觉这是因为v-if的优先级没有v-model高导致的, 所以我们可以通过额外设置一个开关变量来绕开这个报错, 只要不设置对象为null就行.
},
// 清除已完成事项
clearCompleted() {
const originalLength = this.todos.length;
this.todos = this.todos.filter(todo => !todo.completed);
const clearedCount = originalLength - this.todos.length;
console.log('已清除 '+clearedCount+' 个已完成事项');
},
// 格式化日期
formatDate(date) {
return new Date(date).toLocaleDateString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
},
// 生命周期钩子示例
created() {
console.log('TodoApp 组件已创建');
// 从 props 或 localStorage 初始化 todos
if (this.initialTodos && this.initialTodos.length > 0) {
this.todos = [...this.initialTodos];
this.lastTodoId = Math.max(...this.initialTodos.map(todo => todo.id), 0);
} else if (this.enableLocalStorage) {
const savedTodos = localStorage.getItem('vue-todos');
if (savedTodos) {
try {
this.todos = JSON.parse(savedTodos);
this.lastTodoId = Math.max(...this.todos.map(todo => todo.id), 0);
console.log('从 localStorage 加载待办事项');
} catch (e) {
console.error('解析保存的待办事项失败:', e);
}
}
}
},
mounted() {
console.log('TodoApp 组件已挂载到 DOM');
// 为每个待办事项添加双击切换完成状态的功能
this.$nextTick(() => {
const todoItems = document.querySelectorAll('.todo-item');
todoItems.forEach(item => {
item.addEventListener('dblclick', () => {
const todoId = parseInt(item.dataset.id);
const todoIndex = this.todos.findIndex(todo => todo.id === todoId);
if (todoIndex !== -1) {
this.todos[todoIndex].completed = !this.todos[todoIndex].completed;
console.log('双击切换待办事项状态 ID: '+todoId);
}
});
});
});
},
beforeUpdate() {
console.log('TodoApp 组件即将更新');
},
updated() {
console.log('TodoApp 组件已更新');
},
beforeDestroy() {
console.log('TodoApp 组件即将销毁');
},
destroyed() {
console.log('TodoApp 组件已销毁');
}
};
</script>
<style lang="scss" scoped>
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.todo-container {
background: white;
border-radius: 10px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
padding: 30px;
}
header {
text-align: center;
margin-bottom: 30px;
border-bottom: 1px solid #eee;
padding-bottom: 20px;
}
.app-title {
color: #42b983;
font-size: 2.5rem;
margin-bottom: 10px;
}
.app-description {
color: #7f8c8d;
font-size: 1rem;
}
.add-todo {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
.todo-input {
flex: 1;
padding: 12px 15px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 16px;
transition: border-color 0.3s;
}
.todo-input:focus {
border-color: #42b983;
outline: none;
}
.add-btn, .clear-btn {
padding: 12px 20px;
border: none;
border-radius: 6px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s;
}
.add-btn {
background-color: #42b983;
color: white;
}
.add-btn:hover {
background-color: #3aa876;
}
.clear-btn {
background-color: #f0f0f0;
color: #333;
}
.clear-btn:hover {
background-color: #e0e0e0;
}
.filter-controls {
display: flex;
gap: 10px;
margin-bottom: 25px;
}
.filter-btn {
flex: 1;
padding: 10px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s;
}
.filter-btn.active {
background-color: #42b983;
color: white;
border-color: #42b983;
}
.filter-btn:hover:not(.active) {
background-color: #e9ecef;
}
.todo-list {
margin-bottom: 25px;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #95a5a6;
font-style: italic;
}
.todo-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
border-bottom: 1px solid #f0f0f0;
transition: all 0.3s;
border-radius: 6px;
margin-bottom: 8px;
background-color: #f9f9f9;
}
.todo-item:hover {
background-color: #f0f0f0;
transform: translateY(-2px);
}
.todo-item.completed {
opacity: 0.8;
background-color: #f0f9f0;
}
.todo-content {
display: flex;
align-items: center;
gap: 15px;
flex: 1;
}
.todo-checkbox {
width: 20px;
height: 20px;
cursor: pointer;
}
.todo-text {
flex: 1;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.todo-item.completed .todo-text span:first-child {
text-decoration: line-through;
color: #95a5a6;
}
.todo-date {
font-size: 0.8rem;
color: #95a5a6;
}
.todo-actions {
display: flex;
gap: 8px;
}
.edit-btn, .delete-btn {
padding: 6px 12px;
border: none;
border-radius: 4px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.edit-btn {
background-color: #f0f0f0;
color: #333;
}
.edit-btn:hover {
background-color: #e0e0e0;
}
.delete-btn {
background-color: #ff6b6b;
color: white;
}
.delete-btn:hover {
background-color: #ff5252;
}
.stats {
margin-top: 25px;
padding-top: 20px;
border-top: 1px solid #eee;
color: #7f8c8d;
}
.completion-rate {
color: #42b983;
font-weight: bold;
}
.progress-container {
height: 10px;
background-color: #f0f0f0;
border-radius: 5px;
margin-top: 10px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background-color: #42b983;
border-radius: 5px;
transition: width 0.5s ease;
}
footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
text-align: center;
color: #95a5a6;
font-size: 0.9rem;
}
.footer-text {
margin: 5px 0;
}
/* 编辑模态框样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal {
background-color: white;
padding: 30px;
border-radius: 10px;
width: 90%;
max-width: 500px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.modal h3 {
margin-top: 0;
margin-bottom: 20px;
color: #2c3e50;
}
.edit-input {
width: 100%;
padding: 12px 15px;
border: 2px solid #42b983;
border-radius: 6px;
font-size: 16px;
margin-bottom: 20px;
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.save-btn, .cancel-btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
font-weight: bold;
cursor: pointer;
}
.save-btn {
background-color: #42b983;
color: white;
}
.save-btn:hover {
background-color: #3aa876;
}
.cancel-btn {
background-color: #f0f0f0;
color: #333;
}
.cancel-btn:hover {
background-color: #e0e0e0;
}
</style>
// index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>my web components</title>
</head>
<body>
<todo-list app-title="*待办清单*"></todo-list>
<script type="module">
import './src/index.js';
// 执行npm run dev时vite会启动服务并打开index.html, 我们在这个页面中导入src/index.js就能正常执行它里面的代码并注册web-component组件了
</script>
<script src="./dist/index.iife.min.js"></script>
<!-- 也可以执行npm run build直接编译src/index.js到dist目录, 然后在script的src属性中引入打包后的文件, 方案二选一即可 -->
</body>
</html>5. 运行项目
- 如果执行npm run dev, 浏览器打开index.html并运行在localhost下, 此时请参考上面的说明使用import './src/index.js'的方式引入src/index.js.
- 如果运行npm run build, 则会使用vite打包文件到dist目录下的index.iife.min.js, 然后我们手动在index.html中通过script的src属性引入这个打包后的文件, 最后双击index.html打开网页即可.
最后的最后
要是还看不明白, 直接将源码下载下来后, 或者安装npm包后, 在node_modules/@drakchest/wck中有个example文件夹, 双击打开index.html即可看到效果.
您也可以将example文件夹复制到其它地方, 然后执行npm install安装依赖, 最后npm run dev启动开发服务, 并按上面提到的修改index.html里的script部分, 改成直接import 'src/index.js'即可体验效果. 一但正常启动并以localhost打开网页后, 您还可以尝试着修改TodoList.vue代码观察效果.
同理, 在开发模式下, 您还可以在src/components中创建更多的组件并在src/index.js中导入并注册为web-component组件, 在index.html中可以直接使用新注册的web-component组件.
最后, 如果需要异步注册, 编译每个组件为独立js文件等功能, 您可能需要自行在vite.config.js中配置多入口编译, 这不属于本教程的讨论范围. 请自行阅读vite的官方文档.
祝您使用愉快.
