背景:
今天一早洗漱完毕网上冲浪之际,看到托尼的群里一个靓仔问到:
有没有办法让一个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 {
border:5px 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样式用浏览器原生的样式去渲染? 这个问题中学到一部分内容及思考过程;而在我头脑的风暴中,它至今依旧如雪球般滚动着,越滚越大。