用Web Components实现todoList,高效且通用,脱离框架限制

482 阅读5分钟

前言

我们已使用reactvue等框架,编写出todoList,但如何使用Web Components实现它呢?众所周知,Web Components组件的优点为Built once, used everywhere,即只需要编写一次,可以在reactvue微信小程序等框架内使用。听起来很心动,是不?接下来,让我们看看,在Web Components内如何控制状态的变化。

状态

在该应用内,我自定义了4个状态:

  1. readyForAdd:只被add事件触发
  2. readyForAddSelect:被addselect事件触发
  3. readyForAddUnselectDelete:选中全部checkbox后会触发该状态
  4. readyForAddSelectUnselectDelete:选中部分checkbox后会触发该状态

界面效果如下图所示:

ca2c225811ca4cc0a46b83df9789d182.png

369e75052fcd43099b849968cfb89df5.png

状态转换如下表格所示:

Initials StatePre-EventProcessorPost-EventFinal State
unknownStateonloadprocessOnload()onloadSuccessreadyForAdd
readyForAddaddTodoprocessAddTodo()addTodoSuccessNoneSelectedreadyForAddSelect
readyForAddSelectaddTodoprocessAddTodo()addTodoSuccessNoneSelectedreadyForAddSelect
readyForAddSelectchangeTodoprocessChangeTodo()changeTodoSuccessSomeSelectedreadyForAddSelectUnselectDelete
readyForAddSelectchangeTodoprocessChangeTodo()changeTodoSuccessAllSelectedreadyForAddUnselectDelete
readyForAddUnselectDeleteaddTodoprocessAddTodo()addTodoSuccessSomeSelectedreadyForAddSelectUnselectDelete
readyForAddUnselectDeletechangeTodoprocesschangeTodo()changeTodoSuccessNoneSelectedreadyForAddSelect
readyForAddUnselectDeletechangeTodoprocesschangeTodo()changeTodoSuccessSomeSelectedreadyForAddSelectUnselectDelete
readyForAddUnselectDeletedeleteTodoprocessDeleteTodo()deleteTodoSuccessAllDeletedreadyForAdd
readyForAddSelectUnselectDeleteaddTodoprocessAddTodo()addTodoSuccessSomeSelectedreadyForAddUnselectDelete
readyForAddSelectUnselectDeletechangeTodoprocessChangeTodo()changeTodoSuccessAllSelectedreadyForAddUnselectDelete
readyForAddSelectUnselectDeletechangeTodoprocessChangeTodo()changeTodoSuccessSomeSelectedreadyForAddSelectUnselectDelete
readyForAddSelectUnselectDeletechangeTodoprocessChangeTodo()changeTodoSuccessNoneSelectedreadyForAddSelect
readyForAddSelectUnselectDeletechangeTodoprocessChangeTodo()changeTodoSuccessSomeSelectedreadyForAddSelectUnselectDelete
readyForAddSelectUnselectDeletedeleteTodoprocessDeleteTodo()deleteTodoSuccessNoneSelectedreadyForAddSelect

开发该应用的步骤如下所示:

  1. 编写自定义元素custom elements,分别为input-compcheckbox-group-compbutton-comp
  2. 配置上述表格里的状态及事件对象
  3. 编写processor函数,通过对应的custom elements,与web components通信
  4. 编写状态控制器Controller

Web Components

为了简单起见,这三个组件我并未使用Shadow DOM特征

input-comp.js代码:

export default class InputComp extends HTMLElement {
    static get observedAttributes() {
        return ['data-request'];
    }
    
    constructor() {
        super();
    }

    attributeChangedCallback(name, oldVal, newVal) {
        if (newVal != null && newVal != undefined && newVal.length > 0 && name === 'data-request') {
            const data = JSON.parse(newVal);
            if (data != null && data.action === 'create') {
                this.innerHTML = this.createInputTxtElem(data);
            }
        }
        
    }

    createInputTxtElem(data) {
        return `<div id="${data.name}_div">
            <input type="text" name="${data.name}" placeholde="Enter text..."
            value="${data.todoText}">
            </div>`
    }
}

window.customElements.define('input-comp', InputComp);

由于我需要在vue内使用它,故用export default将其作为组件导出

知识点如下:

  1. observedAttributes:获取data-request自定义属性
  2. attributeChangedCallback:当属性变化时,会自动进入到该函数内,当data-request变化时,更改input输入框内容

checkbox-group-comp.js代码:

export default class CheckboxGroupComp extends HTMLElement {
    static get observedAttributes() {
        return ['data-request'];
    }
    constructor() {
        super();
    }

    attributeChangedCallback(name, oldVal, newVal) {
        if (newVal != null && newVal != undefined && newVal.length > 0 && name === 'data-request') {
            let data = JSON.parse(newVal);
            if (data != null && data.action === 'create') {
                this.innerHTML += this.createCheckboxElement(data);
            } else if (data != null && data.action === 'delete') {
                for(let i of data.values) {
                    this.removeChild(document.getElementById("div" + i));
                }
            } else if (data != null && data.action === 'update') {
                for(let el of this.getElementsByTagName('input')){
                    if (!el.checked) {
                        el.removeAttribute('checked');
                    } else {
                        let attr = document.createAttribute('checked');
                        el.setAttributeNode(attr)
                    }
                }
            }

            let nodesArray = Array.from(this.getElementsByTagName('input'));

            let total = nodesArray.length;
            let selected = nodesArray.filter(n => n.checked).map(n => Number(n.value));
            let allItems = nodesArray.map(n => Number(n.value));
            let maxVal = Math.max(...allItems);
            let resp = { itemsCount: total, selectedItems: selected, maxId: maxVal }
            this.setAttribute("data-response", JSON.stringify(resp));
        }
    }
    createCheckboxElement(data) {
        const name = data.name;
        const nextId = data.value;
        const label = data.todoText;

        return `<div id="div${nextId}">
            <input type="checkbox" name="${name}" value="${nextId}"><label id="lbl${nextId}">${label}</label>
            </div>`;    
    }
}

window.customElements.define('checkbox-group-comp', CheckboxGroupComp);

CheckboxGroupComp类有三个actions,如下所示:

  1. create:当输入框按下回车键时,触发该动作
  2. update:当勾选checkbox时,触发该动作,新增或删除当前checkboxchecked属性
  3. delete:当点击Delete按钮时,触发该动作,删除对应的checkbox对象集

button-comp.js代码

export default class ButtonComp extends HTMLElement {
    static get observedAttributes() {
        return ['data-request'];
    }
    constructor() {
        super();
    }
    attributeChangedCallback(name, oldVal, newVal) {
        if (newVal != null && newVal != undefined && newVal.length > 0 && name === 'data-request') {
            const data = JSON.parse(newVal);
            if (this.getElementsByTagName('input')[0] == undefined) {
                this.innerHTML = this.createInputButtonElement(data);
            }
        }
    }
    createInputButtonElement(data) {
        return `<input type="button" name="${data.name}"  class="pure-button" value="${data.value}">`;
    }
}

window.customElements.define('button-comp', ButtonComp);

配置状态及事件对象

todoApp.js里的状态代码如下所示:

const appStates = {
    readyForAdd: function (e) {
        document.getElementById("addTodoView").style.display = "block";
        document.getElementById("changeTodoView").style.display = "none";
        document.getElementById("deleteTodoView").style.display = "none";
        document.getElementById("currentState").innerHTML = e.type + ' => readyForAdd';
    },
    readyForAddSelect: function (e) {
        document.getElementById("addTodo").getElementsByTagName("input")[0].value = "";
        document.getElementById("addTodo").getElementsByTagName("input")[0].focus();
        document.getElementById("addTodoView").style.display = "block";
        document.getElementById("changeTodoView").style.display = "block";
        document.getElementById("deleteTodoView").style.display = "none";
        document.getElementById("currentState").innerHTML = e.type + ' => readyForAddSelect';
    },
    readyForAddUnselectDelete: function (e) {
        document.getElementById("addTodoView").style.display = "block";
        document.getElementById("changeTodoView").style.display = "block";
        document.getElementById("deleteTodoView").style.display = "block";
        document.getElementById("currentState").innerHTML = e.type + ' => readyForAddUnselectDelete';
    },
    readyForAddSelectUnselectDelete: function (e) {
        document.getElementById("addTodoView").style.display = "block";
        if (appData.itemsCount() > 0) document.getElementById("changeTodoView").style.display = "block";
        else document.getElementById("changeTodoView").style.display = "none";
        document.getElementById("deleteTodoView").style.display = "block";
        document.getElementById("currentState").innerHTML = e.type + ' => readyForAddSelectUnselectDelete';
    }
}

appStates对象控制各个组件的显隐,它类似于MVC里的View

todoApp.js的事件配置代码如下所示:

const appEvents = {
    onload: {
        process: function (e, handlePostEvent) {
            const data = { name: "addTodo", action: "create", todoText: "" };
            let el = document.getElementById("addTodo");
            el.setAttribute("data-request", JSON.stringify(data));
            handlePostEvent(new CustomEvent('onloadSuccess'));
        }
    },
    onloadSuccess: {
        nextState: function (e) {
            return appStates.readyForAdd(e);
        }
    },
    addTodo: {
        process: function (e, handlePostEvent) {
            let el = document.getElementById("changeTodo");
            let maxId = appData.maxId() + 1;
            let data = { name: "changeTodo", value: maxId, action: "create", todoText:e.detail.value };
            el.setAttribute("data-request", JSON.stringify(data));

            let evttype = '';
            if (appData.selectedCount() > 0 && appData.itemsCount() - appData.selectedCount() > 0) {
                evttype = 'addTodoSuccessSomeSelected';
            } else {
                evttype = 'addTodoSuccessNoneSelected';
            }
            handlePostEvent(new CustomEvent(evttype));
        }
    },
    addTodoSuccessNoneSelected: {
        nextState: function (e) {
            return appStates.readyForAddSelect(e);
        }
    },
    addTodoSuccessSomeSelected: {
        nextState: function (e) {
            return appStates.readyForAddSelectUnselectDelete(e);
        }
    },
    changeTodo: {
        process: function (e, handlePostEvent) {
            let selectedItem = e.detail.value
            let evttype = '';
            console.log(">>> selected value: ", selectedItem);
            if (selectedItem != null) {
                let data = { name: "deleteTodo", value: "Delete" };
                document.getElementById("deleteTodo").setAttribute("data-request", JSON.stringify(data));

                data = { name: "changeTodo", action: "update", value: Number(selectedItem) };
                document.getElementById("changeTodo").setAttribute("data-request", JSON.stringify(data));
            }
            if (appData.selectedCount() > 0) {
                if (appData.selectedCount() == appData.itemsCount()) {
                    evttype = 'changeTodoSuccessAllSelected';
                } else if (appData.itemsCount() - appData.selectedCount() > 0) {
                    evttype = 'changeTodoSuccessSomeSelected';
                }
            } else {
                evttype = 'changeTodoSuccessNoneSelected';
            }
            handlePostEvent(new CustomEvent(evttype));
        }
    },
    changeTodoSuccessSomeSelected: {
        nextState: function (e) {
            return appStates.readyForAddSelectUnselectDelete(e);
        }
    },
    changeTodoSuccessAllSelected: {
        nextState: function (e) {
            return appStates.readyForAddUnselectDelete(e);
        }
    },
    changeTodoSuccessNoneSelected: {
        nextState: function (e) {
            return appStates.readyForAddSelect(e);
        }
    },
    deleteTodo: {
        process: function (e, handlePostEvent) {
            let data = { name: "changeTodo", action: "delete", values: appData.selectedItems() };
            console.log(">>> data: ", appData.selectedItems());
            document.getElementById("changeTodo").setAttribute("data-request", JSON.stringify(data));

            let evttype = '';
            if (appData.itemsCount() > 0 && appData.selectedCount() == 0) {
                evttype = 'deleteTodoSuccessNoneSelected';
            }
            else evttype = 'deleteTodoSuccessAllDeleted';
            handlePostEvent(new CustomEvent(evttype));
        }
    },
    deleteTodoSuccessNoneSelected: {
        nextState: function (e) {
            return appStates.readyForAddSelect(e);
        }
    },
    deleteTodoSuccessAllDeleted: {
        nextState: function (e) {
            return appStates.readyForAdd(e);
        }
    }
}

appEvents各个事件内,皆定义了nextState函数,而非直接使用appStates,这样我们便能知道该事件对应的下一个状态是什么。process函数处理pre-events,而nextState函数处理post-events,如果大家不清楚的话,可以再次看下文章开头的状态转换表格

Processor函数

有意思的是,Processor函数将JSON数据,添加到data-request属性值里,再收集JSON返回数据,并将其添加到data-response属性值里,在Processor函数内,我们使用appData对象读取data-response值。

appData代码如下所示(也在todoApp.js内)

const appData = {
    maxId: function () {
        let data = document.getElementById("changeTodo").getAttribute("data-response");
        if (data.length > 0) {
            return JSON.parse(data).maxId;
        }
        return 0;
    },
    selectedCount: function () {
        return appData.selectedItems().length;
    },
    itemsCount: function () {
        let data = JSON.parse(document.getElementById("changeTodo").getAttribute("data-response"));
        return data.itemsCount;

    },
    selectedItems: function () {
        let data = JSON.parse(document.getElementById("changeTodo").getAttribute("data-response"));
        return data.selectedItems;
    }
}

MVC模式里,appData类似于Model

Controller

所有的状态控制器函数如下所示(也定义在todoApp.js内)

export function handleAppEvent(customEventName, eventDta) {
    appEventHelper(customEventName, eventDta);
    document.getElementById("addTodo").addEventListener('change', e => {
        appEventHelper('addTodo', e.target.value);
    });
    document.getElementById("changeTodo").addEventListener('change', e => {
        appEventHelper('changeTodo', e.target.value);
    });
    document.getElementById("deleteTodo").addEventListener('click', e => {
        appEventHelper('deleteTodo', e.target.value);
    });
}

export function appEventHelper(customEventName, eventDta){
    let todoEvent = new CustomEvent(customEventName, {
        detail: {
            value: eventDta
        }
    });
    stateTransitionsManager(todoEvent);
}

export function stateTransitionsManager(todoEvent) {
    var todoEventAft = appEvents[todoEvent.type].process(todoEvent, handlePostEvent);
}

export function handlePostEvent(e) {
    appEvents[e.type].nextState(e);
}

值得注意的是,在handleAppEvent函数内,监听并接收所有的HTML DOM事件(pre-events),包括web component里的事件。

状态如何转换

  1. 使用handleAppEvent捕获HTML DOM事件
  2. CustomEvent包裹该事件,并将其发送到stateTransitionsManager
  3. stateTransitionsManager使用appEvents配置,调用对应的processor函数,将其传递到回调函数内
  4. processor函数与对应的web components通信,创建custom event(post-event),将它传递到回调函数handlePostEvent
  5. handlePostEvent利用appEvents配置,调用nexState函数
  6. nexState函数利用appStates配置,控制web component的显隐
  7. 屏幕已准备接收用户的下一步动作

如何应用到Vue项目内

App.vue内,引用自定义元素及todoApp.js代码,如下所示:

<template>
    <section id="todoApp" style="width: 90%">
        <div class="pure-g" style="height: 90%;">
            <div class="pure-u-1-4"> </div>
            <section id="todoApp" class="pure-u-1-2">
                <div id="currentState" class="red"> </div>
                <div class="grids-custom">Add a To-Do:
                    <section id="addTodoView" style="display: none;">
                        <input-comp id="addTodo" data-request="" data-response=""></input-comp>
                    </section>
                    <section id="changeTodoView" style="display: none;">
                        <checkbox-group-comp id="changeTodo" data-request="" data-response=""></checkbox-group-comp>
                    </section>
                    <section id="deleteTodoView" style="display: none;">
                        <button-comp id="deleteTodo" data-request=""></button-comp>
                    </section>
                </div>
            </section>
        </div>
    </section>
</template>

import './src/webcomponents/todoApp/button-comp.js'
import './src/webcomponents/todoApp/input-comp.js'
import './src/webcomponents/todoApp/checkbox-group-comp.js'
import { handleAppEvent } from './src/webcomponents/todoApp/todoApp.js'

mounted() {
    handleAppEvent('onload', '')
}

示例

该应用的示例链接

源码下载

所有的源码可通过github下载

结语

我们可使用MVC模式来控制Web Components的状态转换,如果同学们对此感兴趣的话,可以访问Polymer TodoMVC获得更多资料信息。