前言
我们已使用react、vue等框架,编写出todoList,但如何使用Web Components实现它呢?众所周知,Web Components组件的优点为Built once, used everywhere,即只需要编写一次,可以在react、vue、微信小程序等框架内使用。听起来很心动,是不?接下来,让我们看看,在Web Components内如何控制状态的变化。
状态
在该应用内,我自定义了4个状态:
readyForAdd:只被add事件触发readyForAddSelect:被add、select事件触发readyForAddUnselectDelete:选中全部checkbox后会触发该状态readyForAddSelectUnselectDelete:选中部分checkbox后会触发该状态
界面效果如下图所示:
状态转换如下表格所示:
| Initials State | Pre-Event | Processor | Post-Event | Final State |
|---|---|---|---|---|
| unknownState | onload | processOnload() | onloadSuccess | readyForAdd |
| readyForAdd | addTodo | processAddTodo() | addTodoSuccessNoneSelected | readyForAddSelect |
| readyForAddSelect | addTodo | processAddTodo() | addTodoSuccessNoneSelected | readyForAddSelect |
| readyForAddSelect | changeTodo | processChangeTodo() | changeTodoSuccessSomeSelected | readyForAddSelectUnselectDelete |
| readyForAddSelect | changeTodo | processChangeTodo() | changeTodoSuccessAllSelected | readyForAddUnselectDelete |
| readyForAddUnselectDelete | addTodo | processAddTodo() | addTodoSuccessSomeSelected | readyForAddSelectUnselectDelete |
| readyForAddUnselectDelete | changeTodo | processchangeTodo() | changeTodoSuccessNoneSelected | readyForAddSelect |
| readyForAddUnselectDelete | changeTodo | processchangeTodo() | changeTodoSuccessSomeSelected | readyForAddSelectUnselectDelete |
| readyForAddUnselectDelete | deleteTodo | processDeleteTodo() | deleteTodoSuccessAllDeleted | readyForAdd |
| readyForAddSelectUnselectDelete | addTodo | processAddTodo() | addTodoSuccessSomeSelected | readyForAddUnselectDelete |
| readyForAddSelectUnselectDelete | changeTodo | processChangeTodo() | changeTodoSuccessAllSelected | readyForAddUnselectDelete |
| readyForAddSelectUnselectDelete | changeTodo | processChangeTodo() | changeTodoSuccessSomeSelected | readyForAddSelectUnselectDelete |
| readyForAddSelectUnselectDelete | changeTodo | processChangeTodo() | changeTodoSuccessNoneSelected | readyForAddSelect |
| readyForAddSelectUnselectDelete | changeTodo | processChangeTodo() | changeTodoSuccessSomeSelected | readyForAddSelectUnselectDelete |
| readyForAddSelectUnselectDelete | deleteTodo | processDeleteTodo() | deleteTodoSuccessNoneSelected | readyForAddSelect |
开发该应用的步骤如下所示:
- 编写自定义元素
custom elements,分别为input-comp、checkbox-group-comp、button-comp - 配置上述表格里的状态及事件对象
- 编写
processor函数,通过对应的custom elements,与web components通信 - 编写状态控制器
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将其作为组件导出
知识点如下:
observedAttributes:获取data-request自定义属性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,如下所示:
create:当输入框按下回车键时,触发该动作update:当勾选checkbox时,触发该动作,新增或删除当前checkbox的checked属性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里的事件。
状态如何转换
- 使用
handleAppEvent捕获HTML DOM事件 - 用
CustomEvent包裹该事件,并将其发送到stateTransitionsManager stateTransitionsManager使用appEvents配置,调用对应的processor函数,将其传递到回调函数内processor函数与对应的web components通信,创建custom event(post-event),将它传递到回调函数handlePostEvent内handlePostEvent利用appEvents配置,调用nexState函数nexState函数利用appStates配置,控制web component的显隐- 屏幕已准备接收用户的下一步动作
如何应用到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获得更多资料信息。