事情的起因是我的一个朋友跑来问我如何找到一些Web Component写的组件,因为他的一个项目需要用到一个简单的tree组件。
虽然我不清楚现在什么样的项目会专门需要用到web component来进行开发,不过本着看着不算难,所以要试试的原则,我跟他说,可以试试Svelte。
作为一个
非知名
Svelte布道者,我个人在最近的很长一段时间里都在尝试并使用了Svelte,虽然大部分都是用到了个人项目中,比如我自己个人主页,还有一个简单的开源项目的前端部分。
个人主页 : mowtwo.com / github.com/mowtwo/mow-…
FFServer : github.com/DimCyan/ffs… / github.com/mowtwo/ffse…
在之前我就看到一些用Svelte开发WebComponent的文章,所以在我提出这个建议的时候,我以为事情会变得顺其自然。
不过对方很快给出了一些问题,首先他不是一个专业的前端,Svelte虽然简单,但是目前他还不会。而且其实这次需求下,仅仅只是需要一个tree组件,能够让后端开箱即用。
那要解决问题,就只能考虑另一个办法,那就是现成的组件库。Svelte的冷门程度确实还是超乎了我的想象,虽然常说在国外,Svelte还是有一定的用户的,但是实际上找寻下来,Svelte并没有类似于Vue跟React那种特别通用的组件库。找到的几个大都也是基于Material Design的,组件数量实在很少不说,而且都没有tree组件。
虽然最终我找到了一个基于TailwindCSS,MD设计风格,并且有tree组件。但是这并没有解决我的问题,因为我发现,这玩意样式压根不生效,折腾了半小时我放弃了,可能是我的使用姿势不对吧。
但是作为一个喜欢折腾的人,我不能让我好不容易推荐出去的Svelte成为一个笑话。我打算自己写,对,参考上面找到的那个框架的文档里演示的tree组件,写一个,因为功能看起来并不复杂。
踩坑的开始
编写tree的过程确实不复杂,我也模仿上面的那个框架,选择引入TailwindCSS来快速解决样式问题。
编写出来的代码很简单,实现了一个tree-item组件,然后又封装了一层tree-view来负责实现递归封装,这个组件最终只需要接收一个tree的props,就可以完成一个tree的生成。
下面是tree-item组件的代码,不过是复制的最终完成版本的代码还原的
<script context="module" lang="ts">
export type TreeItemEvents = {
toggleExpand: boolean;
</script>
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { slide } from "svelte/transition";
export let leaf = false;
export let text = "";
export let expand = false;
export let selected = false;
const dispatch = createEventDispatcher<TreeItemEvents>();
</script>
<div class="item">
{#if leaf}
class="cursor-pointer text-gray-600 flex items-center h-40px bg-white transition hover-bg-eee"
style={selected ? "background-color: rgba(119, 197, 250, 0.315)" : ""}
on:click
<div class="ml-4"><slot /></div>
</div>
{:else}
class="cursor-pointer text-gray-600 flex items-center h-40px bg-white transition hover-bg-eee"
on:click={() => {
expand = !expand;
dispatch("toggleExpand", expand);
class="ml-1 text-12px text-gray-600 flex item-center"
style="height: 100%; width:16px;"
<div class="flex items-center transition" class:rotate-90={expand}>
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="16"
height="16"
d="M593.450667 512.128L360.064 278.613333l45.290667-45.226666 278.613333 278.762666L405.333333 790.613333l-45.226666-45.269333z"
p-id="1089"
/></svg
</div>
</div>
<div class="ml-1">{text}</div>
</div>
{#if expand}
<div transition:slide class="ml-1">
<slot />
</div>
{/if}
{/if}
</div>
在网页上,用普通编译模式预览后,我就开始准备编译到WebComponent。
开启WebComponent编译模式
在Svelte中,打包WebComponent无非做两件事,一个就是在打包工具的Svelte插件里开启customElement的编译选项,一个就是给每一个组件都添加一个自定义标签名。
由于我用的是vite创建的项目,所以就是在vite的Svelte插件里设置
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [svelte({
compilerOptions: {
customElement: true,
然后就是给各个组件添加自定义标签名,这个是通过Svelte提供的内置元素实现的
<svelte:options tag="tree-item" />
注意:每一个用到的svelte文件都得加,一开始我以为只需要给导出的那个添加,但是其实是每一个元素都得加
而且还要注意另外一点,svelte组件被注册成WebComponent后,在组件之间互相引用也必须采用WebComponent的方式进入引入,比如我在用tree-view组件包装tree-item的时候,原来的写法是
<script lang="ts">
import TreeItem from "./TreeItem.svelte";
</script>
<TreeItem
text={item.text}
on:toggleExpand={(e) => {
dispatch("itemToggle", {
target: item,
expand: e.detail,
<svelte:self
tree={item.children}
on:leafClick={(e) => {
handleChildrenLeafClick(e);
on:itemToggle={(e) => {
dispatch("itemToggle", e.detail);
</TreeItem>
但是注册后,则改成下面的写法
<svelte:options tag="tree-view" />
<script lang="ts">
import TreeItem from "./TreeItem.svelte";
</script>
<tree-item
text={item.text}
on:toggleExpand={(e) => {
dispatch("itemToggle", {
target: item,
expand: e.detail,
<svelte:self
tree={item.children}
on:leafClick={(e) => {
handleChildrenLeafClick(e);
on:itemToggle={(e) => {
dispatch("itemToggle", e.detail);
</tree-item>
编译并没有遇到太大的问题,不过预览的方式需要进行修改,Svelte默认编译模式下,通过引入App组件然后new App后挂载target到页面中的一个元素下。
而现在则不再需要这些步骤,只需要把需要用到的组件引入,然后在HTML里直接编写WebComponent元素使用就可以。
因此需要继续修改
import "TreeItem.svelte";
import "TreeView.svelte";
<tree-view></tree-view>
</body>
运行dev,在浏览器中没有报错,并且用devtool进行查看,可以发现shadow dom成功渲染。
看起来还算顺利,但是当我们真正开始使用后,新的问题接踵而来。
传递参数问题
我们在上面使用WebComponent时,还没有传递任何的props,但是实际上我们的tree-view组件是需要接受一个叫做tree的对象数组来渲染内容的,因此我们尝试传递
<tree-view tree="[{text:''}]"></tree-view>
</body>
看起来没啥问题,但是实际上我们会得到一个报错。
其实报错的内容完全不需要在意,因为看了也没啥用,问题很明显,那就是通过HTML的attribute直接传递的props,会被当成字符串直接传递。
而且组件内,实际上我们是回去遍历tree数组,然后还要做判断,类似这样
{#each tree as item}
{#if !item.children}
<tree-leaf
selected={item.selected}>{item.text}</tree-leaf
{:else}
<tree-item
text={item.text}>
<svelte:self
tree={item.children}
</tree-item>
{/if}
{/each}
很明显,字符串是没办法实现这个情况的。
在解决问题之前,我们要分析一下这里涉及到两种情况。在我们使用普通的HTML标签的时候,设置标签的属性一般有两种方式,一种就是在HTML里之前给标签添加属性,还有一种就是通过js去直接设置属性或者使用setAttribute。其实setAttribute的情况就跟直接在HTML添加属性是类似的行为,因为setAttribute传递的参数值必须是字符串。
那这里就要单独分析一下直接设置的情况,这里先说一下结论,那就是直接通过js属性的方式设置的值是可以保留类型的。
这个是我在原生网页与Vue内测试后得出的结果,下面给出Vue的案例
<script setup lang="ts">
import { onMounted, ref } from "vue";
const tree = ref([
text: "Hello",
children: [
text: "World",
children: [
text: "!",
const treeRef = ref<{ $on: Function }>();
onMounted(() => {
treeRef.value?.$on("leafClick", function (e:CustomEvent) {
console.log("leafClick",e.detail); // 这里内部自定义事件传递出来的原始对象的Proxy对象,所以保留了Vue的reactive特点,直接修改后可以触发Vue的更新
e.detail.text = '???'
</script>
<template>
<tree-view :tree="tree" ref="treeRef" />
</template>
不过为了能够让WebComponent使用起来更像普通的HTML标签,我们也得兼容一下这种情况,因此我们创建一个新的renderTree来作为最终渲染的数组,而tree改成可以接受数组跟字符串的形式,在组件更新时做一个自动转换,这里其实也可以写成computed的形式,不过我为了方便写判断,所以写成了代码块。
let renderTree: Tree[] = [];
$: {
if (Array.isArray(tree)) {
renderTree = tree;
} else if (typeof tree === "string") {
renderTree = JSON.parse(tree);
} else {
console.warn("tree必须是合法的JSON字符串或对象数组");
样式不生效
解决了各种渲染问题,最终也终于看到页面中出现了内容,但是一个很基础,但是被我忘记的东西出现了。
我之前用了TailwindCSS,样式全部无效了。这里其实简单想想就知道了,WebComponent为了防止样式污染,所以对于整个WebComponent进行了密封,内部的样式无法影响到内部,外部的样式也无法影响到内部,而TailwindCSS的样式是会被编译进单独的CSS文件的。
因此只能将样式重新在模板文件的style里写了一次,不过好歹很有效,样式出现了,虽然跟TailwindCSS写的相比缺少了一些兼容性。
修改后终于可以看到简单的效果,基本符合最初的设计
事件不生效
本来以为事情已经完美解决,但是很快另一个问题就一下又让我浪费了两个小时。
原本以为Svelte打出来的WebComponent基本上符合标准的HTML标签的用法,因为上面的props传递的方式迷惑了我,因此我很自信的认为绑定事件应该也是一样的。
我在测试里写下了
const tree = document.querySelector('tree-view')
tree.addEventListener('leafClick',function(e) {
console.log("leafClick",e.detail)
tree.addEventListener('itemToggle',function(e) {
console.log("itemToggle",e.detail)
结果很快被打脸,压根不生效,一直到这里,我原本以为半小时就能搞定的小问题,其实已经浪费了我一下午将近五个小时的时间。将近崩溃的我只能开始求助于Google(不是没百度,而是百度压根找不到)。
这里其实也是分为两部分解决,并且是在同一个GitHub issue里看到解决方案:
首先就是关于事件的绑定的问题,那就是不能直接使用原生的事件监听系统,Svelte在编译组件的时候会在组件对象看挂载一个自定义的监听器$on
。
将监听修改
const tree = document.querySelector('tree-view')
tree.$on('leafClick',function(e) {
console.log("leafClick",e.detail)
tree.$on('itemToggle',function(e) {
console.log("itemToggle",e.detail)
这样修改后,事件成功触发,但是我很快发现了另外一个问题,就是只有leafClick被触发,而自定义的折叠/展开的触发事件itemToggle无法触发。
这里就不说排查过程,实际解法就是,Svelte的createEventDispatcher创建的dispatch函数是无法将事件传递出WebComponent,原因暂时未知,没有去研究。而其中一个事件会触发的原因是leafClick是直接映射的原生的click事件,这里其实挺奇怪的。
根据issue里解法,就是在触发普通dispatch的时候还需要去调用原生DOM的dispatchEvent,这里要注意在template是没办法直接拿到组件顶层的DOM对象,不过Svelte提供了get_current_component
来获取,所以解决并不复杂,将有使用到dispatch的地方进行简单的改造
const thisComponent = get_current_component();
const svelteDispatch = createEventDispatcher<TreeItemEvents>();
const dispatch: typeof svelteDispatch = (type, detail) => {
thisComponent?.dispatchEvent?.(
new CustomEvent(type, {
detail,
return svelteDispatch(type, detail);
至此问题全部解决,终于结束了。
其实这个开发过程总体都是因为不熟悉各种WebComponent相关的特性造成的问题,所以踩坑在所难免。
不过最终虽然解决了问题,不过个人感觉项目已经基本上算是完蛋了。不过最终还是在朋友@alexzhang1030 (github.com)的帮助下帮忙配置了发包,目前也将开发的包暂时发布到了npm上。
alexzhang1030 的个人主页 - 动态 - 掘金 (juejin.cn)
mowtwo/svelte-tree: 使用svelte构建一个web component的tree组件 (github.com)
@mowtwo/svelte-tree - npm (npmjs.com)
Events are not emitted from components compiled to a custom element · Issue #3119 · sveltejs/svelte (github.com)
Mowtwo
软件研发 @M2Self
粉丝