Web Components:一个需求引发的“学”案

127 阅读6分钟

背景:

今天一早洗漱完毕网上冲浪之际,看到托尼的群里一个靓仔问到:

有没有办法让一个div的css样式用浏览器原生的样式去渲染?

言下之意是他有一段服务端返回的带有行内样式的 HTML 文本,需要渲染到页面上;但由于项目中有全局的reset.css,会把默认的各种标签的样式覆盖掉;而他希望这一段 HTML 文本的所有样式除了行内的其他保持浏览器默认样式。

看到这个问题我当下回了句:用 web-component 吧(其实我自己也对 web-component 一知半解,只大概记得它可以通过 Shadow DOM 进行样式隔离 😂)

回答完之后想着人家也不一定知道你在说啥,本着学到就是赚到的原则,就一边查资料一边写了个简单的 Demo,同时也获得了一些其他的思考;牵扯出了一个惊天 “学” 案。

需求

把带有行内样式的 HTML 文本渲染到页面上面,且不被全局样式 reset.css 污染,仅渲染带有的行内样式及浏览器原始样式。

HTML 文本:

<p style='font-weight: bold;'>this is a p element</p>
<button>this is a button element</button>

reset.css

p {
    font-weight:100;
}
button {
    border5px solid red;
}

分析

此时如果直接把需求中的 HTML 文本渲染到页面上,其文本中的元素样式会被外部全局样式污染到,所以需要寻找方法隔离样式,防止外部样式污染该文本中的元素样式。

什么是 Web Component?

时间关系就不展开,简而言之就是一直浏览器原生支持的编写前端组件的方式,详细可阅读 Web Component

其中,Web Component 中非常重要的一个特性是,它让我们能够将 HTML 页面的功能封装为 自定义标签custom elements),并且可以通过 shadow DOM 对代码进行封装代码,使得代码结构、样式和行为可以被隐藏起来,从而使代码与页面的其他代码进行有效的隔离。

而在当前这个需求中,我们就是对提供的代码进行 有效的隔离

基础解决方案

思路:设置一个安全区域(safe-zone)组件,用于渲染需求中提供的 html 文本

先通过 customElement.define 简单地声明一个自定义组件:

let text = `
    <p style='font-weight: bold;'>this is a p element</p>
    <button>this is a button element</button>
`;

customElements.define(
    "safe-zone",
    class extends HTMLElement {
    constructor() {
	super();
	const shadow = this.attachShadow({
            mode: "open"
	});
	shadow.innerHTML = text;
	}
    }
);

这时在页面中使用 safe-zone,即可渲染出按照需求渲染出对应文本。

更进一步

对于 safe-zone 中的文本,可能实际中会需要动态渲染或频繁改变,所以前面的方案中对于这个情况显然不适合也不优雅。

在常见的前端框架中可以看到,我们常常把需要的参数通过元素 attribute 的方式传入,在这里是否也可以如此处理呢?

那当然。

let text = `
    <p style='font-weight: bold;'>this is a p element</p>
    <button>this is a button element</button>
`;

customElements.define(
    "safe-zone",
    class extends HTMLElement {
	constructor() {
            super();
            const shadow = this.attachShadow({
                mode: "open"
            });
            shadow.innerHTML = text;
            this._shadow = shadow;
        }
        
	static get observedAttributes() {
            return ["content"];
	}
	
	attributeChangedCallback(name, oldVal, newVal) {
            if (name === "content") {
		this._shadow.innerHTML = newVal;
            }
        }
    }
);

这里可以看到,我们使用了两个前面没有的 api

  • attributeChangedCallback:当 custom element 增加、删除、修改自身属性时,被调用。
  • observedAttributes:用于显式地声明要监听的自身属性的静态方法;需要把要监听的属性名在 Array 中返回;否则 attributeChangedCallback 无法监听到属性的更改。

这两个方法都是 custom element 的生命周期回调函数。

此时,我们如果延迟三秒进行内容修改,如下:

setTimeout(() => {
    const ele = document.querySelector("safe-zone");
    ele.setAttribute("content", text);
}, 3000);

safe-zone中的内容也会相应地改变。

再进一步

传参的方式固然解决了动态传入的问题,但是写代码最重要是什么?

优雅! 优雅! 还是 tmd 优雅!(能用就行🤪

对于组件的内容,我们更希望是直接如普通的元素一样,如 <safe-zone>[传入一些文本]</safe-zone> ,要修改则直接修改元素的 innerHTML,这样简单清晰明了,更符合直觉。

于是我们再稍作改造:

let text = `
    <p style='font-weight: bold;'>this is a another p element</p>
    <button>this is a another button element</button>
`;

customElements.define(
    "safe-zone",
    class extends HTMLElement {
	constructor() {
            super();
            const fragment = document.createDocumentFragment();
            fragment.append(...this.childNodes);
            const shadow = this.attachShadow({
		mode: "open"
            });
            shadow.appendChild(fragment);
            this._shadow = shadow;
        }
        
	static get observedAttributes() {
            return ["content"];
	}
        
        set innerHTML(newVal) {
            this._shadow.innerHTML = newVal;
        }
	
	attributeChangedCallback(name, oldVal, newVal) {
            if (name === "content") {
                this._shadow.innerHTML = newVal;
            }
	}
    }
);

此时,我们如果延迟三秒进行内容修改,如下:

setTimeout(() => {
    const ele = document.querySelector("safe-zone");
    ele.innerHTML = text;
}, 3000);

也是可以正确起效的。

其他解决方案

css:unset

也有群友提到,可以使用 all: unset 的方式,去掉元素上的样式;其实不然,如果使用这个方式清除元素的样式,则浏览器默认的样式也会被清除,比如边距、边框,按钮、选择框等元素的默认样式等。所以使用这个方案无法达到“元素用浏览器原生的样式去渲染”的需求。

使用浏览器默认样式表覆盖

还有一种方式,我们可以通过为目标元素设置和浏览器一致的样式表,以使目标元素拥有默认的样式,如 浏览器默认样式汇总;但这种情况非常不灵活,在不同的浏览器中对应的默认样式不一致,而且浏览器的样式表如果有变动我们还要维护,这个方案的实现成本很高,不适用。

总结

至此,我们已经解决了前面的需求;也通过不断地给自己提出更进一步的目标,对开始的代码进行了重构,最终达到了差强人意的效果;并且在这个过程中,对 Web Component 有了一些或者更进一步的了解,也了解了其他的一些解决思路。

当然,方案肯定还是可以不断地优化的,比如 innerHTML 导致的 XSS 攻击隐患;无论是产品还是代码,永远都有进步的余地或学习的空间,不可能一蹴而就;我们只需要从实际的需求出发,遇到可以优化的点再逐步去优化即可。

学习的第一步首先是发问,本文就是我从 有没有办法让一个div的css样式用浏览器原生的样式去渲染? 这个问题中学到一部分内容及思考过程;而在我头脑的风暴中,它至今依旧如雪球般滚动着,越滚越大。