文章较长,耐心观看。
现在再开发一套UI框架似乎已经错过了最佳创业时期,毕竟网上优秀的框架一大堆,轻量级的,重量级的,有依赖的,无依赖的,拿来即用的,需要配置的,应有尽有。但是老衲我找遍整个外网发现唯独没有利用Web Component标准库实现的前端框架,要知道组件化可是Vue,React和Angular的招牌卖点之一,如今Web Component标准库可以完美提供原生的组件化开发模式,这直接意味着前端框架市场仍然有风口,而我选择使用Web组件标准库来开发UI框架的最大卖点是:安全。
UI安全
上图是Google工程师Eric Bidelman对UI安全的描述,他提到现今web app中成千上万行的JavaScript代码连接了各式各样的html和css文件,但缺乏正规的组织形式,变得乱七八糟。类似的话在cn.Vuejs.org官网也说过。
我们常常重视数据安全而忽视了UI的安全。UI上的不安全主要来自2个方面,一个是各种框架同时使用造成的冲突:它们对全局变量的争夺,对html结构和class选择器的滥用经常会导致整个UI结构臃肿不堪,难以维护,很多前端框架都依赖于古老的html-css-js结构,这样框架一多根本没人敢接手,当然,模块化开发就是为了解决这个问题的。第二个不安全的因素是组件内部的过度暴露导致的系统紊乱:组件内部的逻辑结构可以被外界轻易修改是一个安全隐患,这也是Web Component要解决的问题。
不是,组件内部暴露出来不是可以提高自由度,可定制化吗?
更多情况下,组件封装是为了防止你“不小心”篡改了内部信息,比如你能保证你自定义的outerHTML不会被别人的全局CSS作用到吗?你不怕接手的一个项目中,原作者图省事覆盖了框架原来的一些属性,然后你要排查半天吗?以上这些都是过度自由的恶果,为此,适当的封装和隔离是必须的,组件对自身的保护是为了规范用户的操作。
然而,以Vue为代表的前端框架在“组件保护”上做的一塌糊涂:用户可以在不同的地方轻易修改别人的组件而没有任何限制和约束,一旦出了bug就没法精确定位责任人。
影子DOM:子树隔离
影子DOM是Web组件的核心功能,便于理解可以叫它子DOM或者子树。有了子树就实现了一定程度的封装,至少外面的CSS样式进不来了,下图是本文使用的例子。
但注意有一些默认样式找不到的时候会继承外界的样式。此外,从外面用css选择器也找不到:
document.querySelector("p") // null
HTMLElement.prototype.attachShadow这个方法有closed和open两种模式,其实区别只有一个,就是open模式会在元素身上挂载一个shadowRoot的引用,方便随时使用子树,closed模式就不挂。。这种看似鸡肋(因为可以自己挂载)的模式区分其实暗示了2种不同的设计思想:组件的对外开放和闭关锁国。为了安全,给我老老实实的用closed模式。
因为shadowRoot引用可以被组件外面的代码调用,显然是不安全的:
const $myWebComponent = document.querySelector("my-web-component");
$myWebComponent.shadowRoot.querySelector("p").innerText = "Modified!";
当然,所有安全都是相对的,在closed模式下挂载一个自定义的key来引用shadowRoot是一个稍微聪明点的实践,像下面这样在元素身上挂一个“_root”其他人应该猜不到(吧)。
class MyWebComponent extends HTMLElement {
constructor() {
super();
this._root = this.attachShadow({ mode: "closed" });
}
connectedCallback() {
this._root.innerHTML = `
<p>I'm in the closed Shadow Root!</p>
` ;
}
}
window.customElements.define("my-web-component", MyWebComponent);
但仍然不够安全,因为其他同事查查源码就知道你藏在哪了,而且万一“_root”冲突了同名属性怎么办?
怎么办?当然用Symbol啊,Symbol就是专门解决key冲突问题的,可以随时随地的用Symbol()来创建一个全局唯一的uuid(Symbol函数本质是一个自加器)。这样在自定义元素身上挂一个用symbol值来引用的shadowRoot,只要symbol值不要暴露,元素就没办法找到这个引用,就像一个人没法伸手够到自己的后背一样难受(🤭)。
现在的问题是,symbol值藏在哪?由于这个uuid对于每个customElements是唯一的,放在构造函数身上不合适,因为原型函数也需要使用,挂在任何一个原型函数上也不合适,挂在元素自身更不合适,咋整呢?老衲微微一笑:咋们有闭包啊。
闭包+Symbol:完美组合
我一直认为秒杀面试官的诀窍是能够用自己独特的理解来定义任何一个名词,比如我对js闭包的定义是:闭包是一个语法糖,在函数嵌套定义的语法环境下,父函数的环境对象(变量对象)会挂到子函数的作用域链上,这样即使父函数消逝,只要子函数存在,作用域链和父级环境对象就不会被回收。
不过闭包还有一个更棒的好处:闭包函数的环境对象引用自自身的 [[Environment]]属性下,这个对象从函数体外无法访问。可以利用这种隔离来存放我们的symbol。
(()=>{
const shadowId = Symbol();
class MyWebComponent extends HTMLElement {
constructor() {
super();
this[shadowId] = this.attachShadow({ mode: "closed" });
}
connectedCallback() {
this[shadowId].innerHTML = `
<p>I'm in the closed Shadow Root!</p> `;
}
}
window.customElements.define("my-web-component", MyWebComponent);
})();
当然防不胜防,用户甚至可以覆盖Element.prototype.attachShadow函数(🙂),即使不能覆盖也可以修改源码(🙂🙂)。。不过有了closed模式结合闭包和Symbol足够来打造属于我自己的安全组件库了!以上都是铺垫,下面是精彩部分。
打造一套属于自己的UI组件库:UISec
这个项目名字还是比较随意的,logo也是从艺术字库中撸来的(😏)。UISec(UI Security)模仿IPSec以及HTTPS的命名方式,所有的自定义元素就以“uis-”开头,比如<uis-button>,准备主打一套以安全UI为招牌而不是以美观和易用为噱头的产品。项目还是以学习为主,没有任何商业的成分,地址暂时定在:https://github.com/JinHengyu/UISec。从长远发展的眼光,为了实现完美的UISec,需要制定一系列基本准则。
准则一:用户与组件的责任分离
上述所有的安全措施都防止了外界对组件内部的入侵,但想要开发一套安全组件库,还需要阻止内部对外部的恶意输出,为此我制定了一套用户和组件的责任分割线:
对用户来说,用户可以修改组件(自定义element)在3维空间的坐标(x,y,z),如果对应到css暴力定位中,x由left控制,y由top控制,z则由z-index控制,总之对于组件的位移不会影响组件的内容。此外,用户还可以控制组件的容器大小,即控制组件的widht和height,容器大小决定了组件可以自由发挥的空间。用户还决定了组件的生和死,即组件的创建和销毁。
而组件自身能够掌握的主动权力的只有修改自身内容,充其量包括自我销毁的权利,不得干预自己在dom中的位置(x,y,z)和自身的尺寸(width,height)。
准则二:提供覆盖内部CSS样式的接口
除了主动权力,组件的被动权力则包括对外提供的接口,接口可以是setter和getter用来修改内部的数据,更多的时候用户希望能够定制内部的样式,常见的UI插件喜欢提供格式各样的样式套餐,比如下图是element-ui插件提供的各式各样的按钮:
但是无论你搭配多少套餐总是不可能满足所有用户的需求,万一用户想要一个会闪烁的按钮怎么办?不如提供一个可以覆盖内部css样式的接口让用户可以完全定制,从根源上解决极端需求:
<!-- 放在其他style元素之后以达到覆盖的目的 -->
<style id="customStyle"> </style>
get customStyle() {
return this[shadowId].querySelector('style#customStyle').innerHTML;
}
set customStyle(newStyle) {
this[shadowId].querySelector('style#customStyle').innerHTML = newStyle;
}
用户只要稍微看一下shadowRoot内部的html大致结构就可以自由地覆盖内部的css。但组件的设计仍然要以上手即用的样式套餐为主,以customStyle为辅。
准则三:提供快捷方式
这样一来,组件的权力似乎太小了,很多时候用户希望组件可以和外部互动,比如对话框组件的按钮希望能传回调函数,将一个新Promise的resolve函数赋值给按钮的oncilck以便封装成一个异步模块,然后由于对话框需要被append到body下面,fixed成窗口级的元素后才能正常使用。但是根据之前的2个准则,组件本身没有这些操作的权限,只能用户来操作,这样不免有些繁琐,不如我们在组件的构造函数上封装一个这样能够快速生成对话框的工具类方法,提供一种快捷方式给用户可以开箱即用:
await customElements.get('uis-modal').makeAlert({
title: '提示',
content: '云端已更新',
});
console.log('alert对话框已关闭');
准则四:记得归还其他线程上欠下的外债
组件的销毁很简单,但JS里面没有“销毁对象”的说法,只有断开对象的所有引用,所以销毁一个组件通常只要断开dom树对该元素的引用即可,达到这个目的至少有remove,removeChild,replaceWith,replaceChild四个api可用。断开的时候会触发元素自身的disconnectedCallback回调。
组件销毁有时候不够干净,因为组件有可能在使用期间留在其他线程上一些残迹,这些残迹并不会在组件销毁后也随之销毁。要知道浏览器是多线程的,比如在计时器线程上你需要clearInterval或者clearTimeout掉组件触发的计时任务,在事件监听线程上你需要removeEventListener掉某个元素上的事件,避免资源的浪费,如果有这些外债,需要在这disconnectedCallback里完成些操作。
准则五:将数据放在相关的组件下
我以前喜欢把数据挂在相关的dom元素之下,而不是window对象,这样子想要寻找和某个dom元素有关的数据非常方便。比如一个图片轮播的组件就可以把所需的图片列表挂到组件之下,不用挂到window对象之下了。
这种设计模式来源于Vue等框架,虽说数据与UI是分离的,但许多情况下对于一个功能,是可以将相关的数据和相关的UI组件放在一块儿。比如“轮播”这个功能可以将轮播窗口和图片列表绑定在一起,找起来也方便。
准则六:嵌入式wiki
有时候一个框架的官网打不开就会很着急,没有文档寸步难行。为何不把一些简短的文档直接存在组件的构造函数中呢?通过wiki函数将一些关键信息打印在console中或者其他地方,比如下面这样:
static get wiki() {
console.table({
version: { info: '0.1' },
description: { info: '这是一个modal组件' },
makeAlert: {
info: '创建alert的快捷方式',
params: 'title: string, content: string',
return: 'Promise'
},
'...': { info: '...' },
});
}
暂时想到这6个UISec的基本准则,以后有新的再补充,或许UISec在这个严格的基础上会发展起来成为小众软件,或许UISec项目永远不会真正流行。那都无所谓,至少从使用框架到有勇气设计框架,我们走出了一大步。
参考资料
https://developers.google.com/web/fundamentals/web-components/shadowdom
https://blog.revillweb.com/open-vs-closed-shadow-dom-9f3d7427d1af
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures
(完)
【日记】
分享一个好玩的“键盘字符画”:
/***
* ┌───┐ ┌───┬───┬───┬───┐ ┌───┬───┬───┬───┐ ┌───┬───┬───┬───┐ ┌───┬───┬───┐
* │Esc│ │ F1│ F2│ F3│ F4│ │ F5│ F6│ F7│ F8│ │ F9│F10│F11│F12│ │P/S│S L│P/B│ ┌┐ ┌┐ ┌┐
* └───┘ └───┴───┴───┴───┘ └───┴───┴───┴───┘ └───┴───┴───┴───┘ └───┴───┴───┘ └┘ └┘ └┘
* ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───────┐ ┌───┬───┬───┐ ┌───┬───┬───┬───┐
* │~ `│! 1│@ 2│# 3│$ 4│% 5│^ 6│& 7│* 8│( 9│) 0│_ -│+ =│ BacSp │ │Ins│Hom│PUp│ │N L│ / │ * │ - │
* ├───┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─────┤ ├───┼───┼───┤ ├───┼───┼───┼───┤
* │ Tab │ Q │ W │ E │ R │ T │ Y │ U │ I │ O │ P │{ [│} ]│ | \ │ │Del│End│PDn│ │ 7 │ 8 │ 9 │ │
* ├─────┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴─────┤ └───┴───┴───┘ ├───┼───┼───┤ + │
* │ Caps │ A │ S │ D │ F │ G │ H │ J │ K │ L │: ;│" '│ Enter │ │ 4 │ 5 │ 6 │ │
* ├──────┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴────────┤ ┌───┐ ├───┼───┼───┼───┤
* │ Shift │ Z │ X │ C │ V │ B │ N │ M │< ,│> .│? /│ Shift │ │ ↑ │ │ 1 │ 2 │ 3 │ │
* ├─────┬──┴─┬─┴──┬┴───┴───┴───┴───┴───┴──┬┴───┼───┴┬────┬────┤ ┌───┼───┼───┐ ├───┴───┼───┤ E││
* │ Ctrl│ │Alt │ Space │ Alt│ │ │Ctrl│ │ ← │ ↓ │ → │ │ 0 │ . │←─┘│
* └─────┴────┴────┴───────────────────────┴────┴────┴────┴────┘ └───┴───┴───┘ └───────┴───┴───┘
*/
转载:https://blog.csdn.net/github_38885296/article/details/100131955