原生Web Component构建可重用组件的技巧

367 阅读3分钟

大家好,我是Jcode!

前言

近期手头在做一个html原生项目,需要使用组件化来构建代码,方便后面的维护、重用和解耦。 但是html怎么写原生组件呢?vue和React的组件写法,相信各位佬都是手到擒来的👍,那html组件的写法又有什么差异,需要注意什么?

概念

Web Component实际本身就是一个自定义的HtmlElement,能够创建可复用、封装良好的自定义元素的UI组件,它支持:

  1. 样式和事件相关隔离影子Shadow(避免造成与原生代码冲突和样式污染)。
  2. 插槽slot的使用,不同于Vue的是不能传递作用域插槽,有具名插槽和默认的插槽。
  3. HTML Template:定义可复用的模板结构

在开发场景中,一些常见的对话框组件、下拉组件和特定功能的UI数据组件。

例子

对话框的自定义组件实现

  1. 模版

单独把模版放在html中,方便维护。也可以放在js中使用模版字符串去声明成一串字符串。

<!-- index.html -->
<style>
	.dialog {
		background-color: #d1e2e9;
		padding: 20px;
		border-radius: 5px;
		width: fit-content;
		min-width: 300px;
		min-height: 200px;
		position: absolute;
		cursor: move;
	}
	.dialog-header {
		justify-content: space-between;
		align-items: center;
		margin-bottom: 10px;
	}
	.dialog-footer {
		justify-content: flex-end;
		margin-top: 10px;
	}
	.close-button {
		position: absolute;
		top: 10px;
		right: 10px;
		cursor: pointer;
	}
</style>
<div class="dialog">
	<div class="close-button" tabindex="0" role="button" aria-label="Close dialog">&#x2715;</div>
	<div class="dialog-header">
		<slot name="header">title</slot>
	</div>
	<div class="dialog-content">
		<slot></slot>
	</div>
	<div class="dialog-footer">
		<slot name="footer">
			<button class="footer-button button button-primary" id="confim">Confirm</button>
			<button class="footer-button button button-primary" id="cancel">Cancel</button>
		</slot>
	</div>
</div>
  1. 组件
//index.js
class XLayoutDialog extends HTMLElement {
	constructor() {
		super();
		this._top = '50%';
		this._left = '50%';
		this.attachShadow({ mode: 'open' });//打开影子DOM模式
	}
        //监听变化的属性 
	static get observedAttributes() {
		return ['left', 'top'];
	}
	// 元素添加到文档中时调用
	async connectedCallback() {
		await this.loadTemplate();
	}
	//属性更改、添加、移除或替换时调用 对应attributeChangedCallback函数写实现
	attributeChangedCallback(name, oldValue, newValue) {
		if (name === 'left') {
			this._left = newValue;
		} else if (name === 'top') {
			this._top = newValue;
		}
		if (this.dialog) this.updateOptions();
	}
  //动态加载模版 
	async loadTemplate() {
		try {
			const response = await fetch('/x-dialog-layout/index.html');
			const text = await response.text();
                   //创建Fragment片段
			const template = document.createRange().createContextualFragment(text);
			this.shadowRoot.appendChild(template);
			this.style.display = 'none';
			this.initDialog();
			this._render();
                        //发送自定义事件
			this.dispatchEvent(new CustomEvent('load',{bubbles:true,cancelable: true});
		} catch (error) {
			console.error('Failed to load template:', error);
		}
	}
  //初始化事件监听
	initDialog() {
		this.closeIcon = this.shadowRoot.querySelector('.close-button');
		this.dialog = this.shadowRoot.querySelector('.dialog');
		this.closeIcon.addEventListener('click', () => this.close());
		this.dialog.addEventListener('mousedown', e => this.startDrag(e));
		document.addEventListener('mousemove', e => this.drag(e));
		document.addEventListener('mouseup', () => this.endDrag());
		this.shadowRoot.querySelector('#confim').addEventListener('click', () => {
			this.dispatchEvent(new CustomEvent('confirm', { bubbles: false, composed: false }), () => {
				this.close();
			});
		});
		this.shadowRoot.querySelector('#cancel').addEventListener('click', () => this.close());
	}

	_render() {
		this.updateOptions();
	}
	set top(top) {
		this._top = top;
	}
	set left(left) {
		this._left = left;
	}
	open() {
		this.style.display = 'block';
	}
	updateOptions(isInit = true) {
		Object.assign(this.dialog.style, {
			top: this._top,
			left: this._left,
			transform: isInit ? `translate(-${this._left}, -${this._top})` : 'none',
		});
	}
	close() {
		this.style.display = 'none';
	}

	startDrag(e) {
		this.dragging = true;
		const { left, top } = this.dialog.getBoundingClientRect();
		this.offsetX = e.clientX - left;
		this.offsetY = e.clientY - top;
	}

	drag(e) {
		if (!this.dragging) return;
		let x = e.clientX - this.offsetX;
		let y = e.clientY - this.offsetY;
		const dialogRect = this.dialog.getBoundingClientRect();
		const maxLeft = window.innerWidth - dialogRect.width;
		const maxTop = window.innerHeight - dialogRect.height;
		x = Math.min(Math.max(0, x), maxLeft);
		y = Math.min(Math.max(0, y), maxTop);
		this._top = `${y}px`;
		this._left = `${x}px`;
		this.updateOptions(false);
	}

	endDrag() {
		this.dragging = false;
	}
}
//判断是否已经注册过
if (!customElements.get("x-dialog-layout")) {
    customElements.define('x-dialog-layout', XLayoutDialog);
}


技巧

  1. 使用customElements.define注册的自定义组件是全局注册的。不能支持按需加载,只需要执行一次就行。
  2. 在自定义的组件上不能绑定浏览器不支持解析的dom事件(例如自定义事件),只能通过Dom.addEventListener监听,当然这里也有一个暗渡陈仓的办法。
// bubbles: false 不冒泡,composed: false 仅在影子 DOM 内部传播,不会影响到外部 DOM

//手动触发load事件
this.dispatchEvent(new CustomEvent('load', { bubbles: false, composed: false }),this);

//监听事件
<x-dialog-layout onload="loadComponent(this)"></x-dialog-layout>

  1. 通过fetch获取的html模版中如果含有script标签且使用innerHTML生成dom的情况下,因为安全原因可能不会执行script,此时则可以使用appendChild方法。

欢迎指正!