一次Web Components的实践

731 阅读7分钟

"组件"这个词在 Vue、React等MVVM前端框架兴起以来,一直是web前端开发经久不衰的热议话题。在Vue里面有template组件,jsx/tsx组件之争;在React里有Class Component和Function Component的交替。值得注意的是尽管以上提到的都被称为组件且都能在正确的运行时下编译成对应的HTML元素与JS逻辑,但它们都只是只能在特定的框架或者特定的babel库下才能运行成功。也就是说这些所谓的组件对于不同种类,不同版本的框架,是无法实现通用的。
再回到前端范围中的组件定义:就是组成页面内容的零件,它是HTML结构和CSS样式的综合体,组件本身并无交互效果;且其同时还具备以下几个特性:

  1. 组件是页面中公共的、相对独立的最小单位

  2. 组件是由一个或多个HTML元素组成的集合

  3. 组件可以没有功能,但必须拥有数据,一个空标签集合不能称作有效的组件

在目前工作中,我们往往很容易就根据需求与交互在项目开发过程中划分一个个各司其职的组件,然后再通过拼接这些组件完成页面。在划分组件的过程中,对于一些仅用于展示但是又具有较为复杂的内部状态的组件,除了基于项目中正在使用的框架去定义并实现组件外。也不妨可以使用Web Components来实现组件。

Web Components 是一套不同的技术,允许您创建可重用的定制元素(它们的功能封装在您的代码之外)并且在您的web应用中使用它们。使用Web Components封装的组件相较于基于某个特定前端框架封装的组件,其优势在于:

  1. 仅依赖于浏览器提供的接口,不受特定框架的限制,这意味着其可以一次编写,使用在基于不同前端框架的不同项目中且不需要额外安装任何依赖

  2. 足够独立且对于全局的JS环境污染极小(事实上一个Web Component往往会对应一个Class)

  3. 具有和Vue组件或者React组件大部分功能(仅仅是不能与响应式系统交互),但具有直接操作DOM的能力

Web Components旨在解决这些问题 — 它由三项主要技术组成,它们可以一起使用来创建封装功能的定制元素,可以在你喜欢的任何地方重用,不必担心代码冲突。

  1. Custom elements(自定义元素):一组JavaScript API,允许您定义custom elements及其行为,然后可以在您的用户界面中按照需要使用它们。
  2. Shadow DOM(影子DOM):一组JavaScript API,用于将封装的“影子”DOM树附加到元素(与主文档DOM分开呈现)并控制其关联的功能。通过这种方式,您可以保持元素的功能私有,这样它们就可以被脚本化和样式化,而不用担心与文档的其他部分发生冲突。
  3. HTML templates(HTML模板):<template> 和 <slot> 元素使您可以编写不在呈现页面中显示的标记模板。然后它们可以作为自定义元素结构的基础被多次重用。

实现web component的基本方法通常如下所示:

  1. 创建一个类或函数来指定web组件的功能,如果使用类,请使用 ECMAScript 2015 的类语法(参阅类获取更多信息)。 使用 CustomElementRegistry.define() 方法注册您的新自定义元素 ,并向其传递要定义的元素名称、指定元素功能的类、以及可选的其所继承自的元素。
  2. 如果需要的话,使用Element.attachShadow() 方法将一个shadow DOM附加到自定义元素上。使用通常的DOM方法向shadow DOM中添加子元素、事件监听器等等。
  3. 如果需要的话,使用 <template> 和<slot> 定义一个HTML模板。再次使用常规DOM方法克隆模板并将其附加到您的shadow DOM中。
  4. 在页面任何您喜欢的位置使用自定义元素,就像使用常规HTML元素那样。

下面是一个基于Web Components序列帧动画组件的简单实现:

首先,让我们来看看卖家秀:

www.protohomes.com/

image.png 显然可以看到这个公司的官网,非常有创意地将全屋装修通过一个动画展示出来,且这个动画的播放与用户滚动行为进行了绑定。点开检查元素,发现这个随滚动行为播放的动画也不是一个常用的HTML元素:

image.png

显然这里是用Web Components实现了一个随着用户滚动播放动画的组件。通过查看其网络请求,不难发现这个所谓的动画其实是由80张图片随着滚动行为变换而构成的。

image.png

那就开始抄一下这个组件咯! 以下是买家秀:

image.png

接下来就看看具体的代码实现: 首先是html文件:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="./style.css">
    <title>Document</title>
</head>
<body>
    <div class="container">
        <div class="content">
            <div class="title">Hello World</div>
            <image-sequence
            image-height="calc(100vh - 20px)" 
            image-prefix="./image/sequence/ProtohomesAssembling"
            image-ext-name=".jpg" start-index=0 end-index=80
            end-image-path="./image/full-desktop.jpg"
            end-video-path="./image/video-desktop.mp4"></image-sequence>
            <div class="placeholder">
                <ul>
                    <li>1</li>
                    <li>2</li>
                    <li>3</li>
                    <li>4</li>
                    <li>5</li>
                    <li>6</li>
                    <li>7</li>
                    <li>8</li>
                    <li>9</li>
                    <li>10</li>
                </ul>
            </div>
        </div>
    </div>
    <script src="./main.js"></script>
    <script>
        customElements.define('image-sequence',imageSequence);
    </script>
</body>
</html>

在html文件中的第二个script标签内,通过customElements.define()定义了一个叫做image-sequence的自定义组件.而这个image-sequence组件的内部组件实现就放在main.js中了:

class imageSequence extends HTMLElement{
    constructor(){
        super();
        this.recordAttributes();
        this._shadow = this.initDOM();
        this.initEventListener(this._shadow)
    }

    recordAttributes(){
        try{
            this.imagePrefix = this.getAttribute('image-prefix');
            this.imageExtName = this.getAttribute('image-ext-name');
            this.startIndex = parseInt(this.getAttribute('start-index'),10);
            this.endIndex = parseInt(this.getAttribute('end-index'),10);
            this.endImagePath = this.getAttribute('end-image-path');
            this.endVideoPath = this.getAttribute('end-video-path');
            this.imageHeight = this.getAttribute('image-height')
        }catch(e){
            throw e;
        }
    }

    initDOM(){
        let shadow = this.attachShadow({mode:'open'})//开启shadow DOM,创建shadow Root
        let initImageName = `${this.imagePrefix}${this.startIndex > 10?this.startIndex:'0'+this.startIndex}${this.imageExtName}`
        let videoPath = this.endVideoPath;
        const DOMTree = {
            tagName:'div',
            props:{class:'image-view-port'},
            children:[
                {
                    tagName:'div',
                    props:{class:'image-container'},
                    children:[
                        {
                            tagName:'div',
                            props:{class:'image-wrap'},
                            children:[
                                {
                                    tagName:'img',
                                    props:{id:'image',src:initImageName},
                                    children:[]
                                }
                            ]
                        },
                        {
                            tagName:'video',
                            props:{id:'video',src:videoPath,autoplay:'autoplay',loop:'loop'},
                            children:[]
                        }
                    ]
                }
            ]
        }
        const {tagName,props,children} = DOMTree
        let imageViewPort = this.TagFactory(tagName,props,children)
        let styleDOM = document.createElement('style')
        styleDOM.textContent = `
        .image-view-port{
            height: ${this.imageHeight};
            overflow-y: auto;
            overflow-x: hidden;
        
        }
        
        .image-container{
            height: calc(${this.imageHeight} + 1650px);
            display: flex;
            align-items: center;
            flex-direction: column;
            position: relative;
        }

        .image-wrap{
            height: ${this.imageHeight};
            overflow: hidden;
            z-index: 2;
        }

        #image{
            height: ${this.imageHeight};
        }

        #video{
            height: ${this.imageHeight};
            position: absolute;
            z-index: 1;
        }
        
        .image-view-port::-webkit-scrollbar {
            width:0px;
            height:0px;
        }
        .image-view-port::-webkit-scrollbar-button    {
            background-color:rgba(0,0,0,0);
        }
        .image-view-port::-webkit-scrollbar-track     {
            background-color:rgba(0,0,0,0);
        }
        .image-view-port::-webkit-scrollbar-track-piece {
            background-color:rgba(0,0,0,0);
        }
        .image-view-port::-webkit-scrollbar-thumb{
            background-color:rgba(0,0,0,0);
        }
        .image-view-port::-webkit-scrollbar-corner {
            background-color:rgba(0,0,0,0);
        }
        .image-view-port::-webkit-scrollbar-resizer  {
            background-color:rgba(0,0,0,0);
        }
        .image-view-port::-webkit-scrollbar {
            width:10px;
            height:10px;
        }
        `

        shadow.appendChild(styleDOM) // append style DOM
        shadow.appendChild(imageViewPort)//append HTML Elements
        return shadow
    }


    initEventListener(_shadow){
        const viewPort = _shadow.querySelector('.image-view-port')
        const imageContainer = _shadow.querySelector('.image-container')
        const imageWrap = _shadow.querySelector('.image-wrap');
        const imageNode = _shadow.querySelector('#image');
        const videoNode = _shadow.querySelector('#video');

        const playHeight = imageContainer.clientHeight - imageNode.clientHeight;
        imageContainer.style.height = `${imageContainer.clientHeight + imageNode.clientHeight}px`

        viewPort.addEventListener('scroll',(e) => {
            if(e.target.scrollTop < playHeight){
                imageWrap.style.height = `${imageNode.clientHeight}px`
                imageWrap.style.transform = `translateY(${e.target.scrollTop}px)`
                videoNode.style.transform = `translateY(${e.target.scrollTop}px)`
                const imageIndex = parseInt(e.target.scrollTop / 20)
                this.locatePic(_shadow,imageIndex,e.target.scrollTop > 20 * (this.endIndex - this.startIndex))
            }else{
                if(e.target.scrollTop <= playHeight + imageNode.clientHeight){
                    let newHeight = imageNode.clientHeight - (e.target.scrollTop - playHeight)
                    imageWrap.style.transform = `translateY(${e.target.scrollTop}px)`
                    videoNode.style.transform = `translateY(${e.target.scrollTop}px)`
                    imageWrap.style.height = `${newHeight}px`
                }else{
                    imageWrap.style.height = `0px`
                }
            }
            
        })
    }

    locatePic(_shadow,_index,isEnd = false){
        if(_index > this.endIndex + 1 || _index < this.startIndex){
            return 
        }
        let sequenceNumber = _index < 10 ? '0'+ _index: _index;
        const imageName = isEnd?this.endImagePath:`${this.imagePrefix}${sequenceNumber}${this.imageExtName}`
        _shadow.querySelector('#image').setAttribute('src',imageName)
    }

        
    TagFactory(tagName,props,children){
        try{
            let root = document.createElement(tagName)

            Object.entries(props).forEach(([k,v]) => {
                root.setAttribute(k,v)
            })

            let childrenDOMList = children.map(item => {
                if(typeof item === 'object'){
                    return this.TagFactory(item.tagName,item.props,item.children)
                }else if(typeof item == 'string'){
                    return document.createTextNode(item)
                }else{
                    return document.createTextNode(item.toString())
                }
            })

            childrenDOMList.length > 0 && childrenDOMList.forEach(child => {
                root.append(child)
            })

            return root
        }catch(e){
            throw e;
        }
    }
}

如代码所示,在买家秀中的web Component是用class来实现image-sequence的逻辑的。 在类的构造函数中,调用了

this.recordAttributes();
this._shadow = this.initDOM();
this.initEventListener(this._shadow)

this.recordAttributes()是用于收集自定义组件上添加的属性值,并做相关处理并将这些处理后的属性值赋给类的成员变量.
this._shadow = this.initDOM()是用于初始化自定义组件的shadow DOM结构,并返回自定义组件的shadow实例. this.initEventListener(this._shadow)是用于为自定义组件添加滚动时间监听函数,同步图片展示下标.

另外:
locatePic(_shadow,_index,isEnd = false)用于切换自定义组件展示的图片. TagFactory(tagName,props,children)使用递归实现根据对象构建DOM结构.

整个image-sequence自定义组件的实现难点主要在滚动回调函数中边界值的设置,具体可以仔细查看代码
总结下来,使用web Component构建的自定义组件步骤大概可以分为:

  1. 通过script引入相应的Class

  2. 通过customElements.define() API注册自定义组件

  3. 在html中直接使用定义的标签,并传入相应的属性

以上就是我对web Component的一次拙劣的探索与实践过程啦,希望能够给大家带来帮助。