使用 AJAX 与 Shadow DOM 引入 html

在一个html中,想引入另一个html,最简单的方法是使用iframe, 但iframe的难点在于高度固定,很难根据文档高度自适应高度。 本文另辟蹊径,使用 AJAX 及 Shadow DOM 来解决这个难题

背景

项目文档使用.docx或.md格式编写,现有需求把文档转成html,放到独立的静态服务器中,把相关地址存入数据库。在用户通过浏览器访问另一个项目时,有一个接口会返回文档生成的html的地址,则我要做的是:根据地址,拿到html,再把其内容展示到当前页面中。

约定:把引入html的文档称之为主文档(main documentmaster document)

iframe

使用iframe是最简单的方法,但有一个问题:必须预先设置iframe的高度,如果文档内容过长,iframe内部就会产生一个滚动条。如果当前文档内容已经超出了视窗高度,则当前文档也会有一个滚动条,所以很容易出现有两个滚动条的页面,难看得要死,滚来滚去的也不方便,总之用户体验不好。如果想把iframe的滚动条去掉,请继续往下看。

此时的解决思路是:iframe加载完成后,使用js获取iframe内容的高度,重设iframe高度。

然而,这必须是当前文档与iframe文档处于同源情况下才能做到,而我所遇到的是喜闻乐见的跨域情况,此时浏览器不允许我用js访问iframe内document对象

难道跨域就没法玩了?也不尽然,可以使用这个类库https://github.com/davidjbradshaw/iframe-resizer,解决跨域iframe高度自适应问题。不过这有个前提,那就是你必须修改需要引入的html,详情可见这篇文章的分析https://css-tricks.com/cross-domain-iframe-resizing

虽然我拥有html的控制权,但html是由.docx或.md文件生成的,并不是源文件。如果这次我通过修改html来解决滚动条的问题,那以后文档修改了,重新生成html,我又得再修改html,维护性低,我决定找找更好的方法

HTML Imports

我首先想到的是Web Components的HTML Imports

1
2
3
4
<!-- Resources on other origins must be CORS-enabled. -->
<head>
<link rel="import" href="/path/to/imports/stuff.html">
</head>

当然这里有个前提条件,服务端必须开启CORS,以Nginx为便,配置如下

1
2
3
server {
add_header Access-Control-Allow-Origin *;
}

页面上有多个相同URI资源的HTML Imports,浏览器只会加载一次

HTML Import完成时,会触发onload事件,可以使用js动态创建HTML Imports的link标签

1
2
3
4
5
6
let link = document.createElement('link')
link.rel = 'import'
link.href = 'file.html'
link.onload = function(e) {...}
link.onerror = function(e) {...}
document.head.appendChild(link)

经测试,Vue2.x无法对link标签绑定事件,即不支持以下写法

1
<link rel="import" href="file.html" @load="onload"></link>

如果使用Vue,则必须在的mounted钩子里动态创建link标签

触发onload事件后,html需要引入进来了,但是内容并没有呈现,还需要js控制一下

1
2
let content = document.querySelector('link[rel="import"]').import
document.body.appendChild(content.documentElement.cloneNode(true))

不过存在的问题是,import进来的html如果有style元素,或有外链样式,则HTML Imports onload事件触发后,引入进来的html的样式会影响主文档,造成全局样式污染

所以说,先不论兼容性的问题,这个样式被污染的问题,就很蛋疼。关于这个问题,谷歌团队在github已有相关资料,最终的结论是:

目前HTML Imoprts的样式表(内联在<style>, 外链样式<link rel="stylesheet">)会作用在主文档上。反对并最终会移除这种行为,把HTML Imports标准的第九章节也移除掉。当前的计划是,在Chrome61(2017年9月)开始在控制台显示警告信息,最终在Chrome65移除该行为(2018年5月)

之所以这样做是因为,目前而言HTML Imports只有Blink内核才生效,也即Safari,Firefox,IE,Edge是不支持的,而HTML Imports的样式表的规则应用到主文档上,既给开发者都带来困扰,也增加了Blink内核的复杂度,所以谷歌团队最终决定:

移除HTML Imports行为,而反对其中的样式系统是整个计划的第一步

看到这里,我也是惊呆了,居然HTML Imports不被鼓励使用,而且最终要弃用,难怪谷歌开发者文档上Web Components里的内容主要讲Shadow DOMCustom Elements, 文档本身并没有多少HTML Imports的内容。

我再上chromium论坛查看相关讨论 ,看到移除HTML Imports的日子还未确定,于是决定还是先用着顶下数,解决眼前的问题先。

Shadow DOM

上面提到,HTML Imports进来后,引入文档的样式污染主文档了。如果引入文档的样式是scoped styles,就可以解决上述问题,而这正是Shadowm DOM做的事情。

因为<link rel="import" href="/path/to/imports/stuff.html">一旦加载,样式就会作用到主文档,所以我的想法是在Shadow DOM里创建HTML Imports的link元素,link加载完成,整个样式是scoped styles, 就不会污染全局了

于是有代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mounted () {
let container = document.querySelector('.container')
let shadowRoot = container

// https://developers.google.com/web/fundamentals/web-components/shadowdom?hl=en#support
if (!!HTMLElement.prototype.attachShadow)
shadowRoot = container.attachShadow({mode: 'open'})

let link = document.createElement('link')
link.rel = 'import'
link.href = 'file.html'
link.onload = function(e) {}
link.onerror = function(e) {}

shadowRoot.appendChild(link)
}

想法很好,但实际上却只得到控制台的一个警告

HTML element is ignored in shadow tree.

好吧,想通过link来import HTML的想法泡汤了,只能使用最后的大招了

AJAX + Shadow DOM

思路是:AJAX获取CROS的html,然后把html内容放到Shadow DOM中

直接上代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
mounted() {
let container = document.querySelector('#container')
let shadowRoot = container

// https://developers.google.com/web/fundamentals/web-components/shadowdom?hl=en#support
if (!!HTMLElement.prototype.attachShadow)
shadowRoot = container.attachShadow({mode: 'open'})

this.$axios.$get(this.url,).then(resp => {
// FIXME 把前缀添加到link href, img src属性中, 因为目前他们都是相对路径。
// 未做绝对路径的判断, 需要完善
let prefix = this.url.replace(/([a-zA-Z]+\.html)/, '')

let newHtml = resp.replace(/(<link href=")/g, "$1" + prefix)
newHtml = newHtml.replace(/(<img src=")/g, "$1" + prefix)
shadowRoot.innerHTML = newHtml
})
},

思路不难,主要障碍在于另外两个问题:

  1. 文件乱码
  2. 资源路径

这两个问题使用iframe都不会有,所以说如果不是因为滚动条问题,iframe真的是省心呀。

下面分享这两个问题的解决思路

文件乱码

因为html是由.docx或.md转换而成的,所以乱码问题又可以分为两个方面

  1. 导出的html编码不对
  2. 浏览器没有使用正确的编码解析文档

解决方案如下:

  1. 建议使用Mac/Linux机器
  2. 找一个靠谱的转换html的工具docx转换html可以使用这个,md转换html可以使用MacDown, Typora或其他,不建议使用马克飞象,原因后面会说
  3. 确保导出的html编码格式为UTF-8,可以使用命令file查看
  4. 修改Nginx配置,在http模块设置charset utf-8;

资源路径

众所周知,图片是个老大难问题,难点有二:

  1. 截图/复制图片到文档里
  2. 图片地址

写.docx的时候,1不是问题; 至于2,图片是保存在文档内的,导出html时,会有一个文件夹专门放图片,此时html里对图片的引用路径是相对路径

写.md的时候,一般没有截图/复制的功能,如果有,一般也是自己找个靠谱的图床并安装一些插件,需要折腾一番,或使用付费服务; 此时文档里图片是外链,也即图片是绝对路径,此时转成html不需要一个图片文件夹。

如果不想折腾,又不想给钱,就得截图然后保存到本地,也即本地需要一个文件夹存放图片,导出的html图片引用是相对路径。

有人会想到马克飞象,它既可以截图/复制图片,又可以只导出一个html文件,图片是作为Base64形式内嵌其中的,看起来很炫酷,实则一不能让浏览器缓存图片,二让html变得臃肿无比,并不推荐

最终,不想花钱又不想再折腾的我们,选择的做法是跟.docx一样,导出html后,本地有一个图片文件夹,html里图片的路径是相对路径

另外, 仍然建议使用Mac/Linux机器进行上述操作,因为Windows的目录符与是\, 而URL的目录符其实是/

那么,问题就来了:

A站点,使用AJAX获取到B站点的文档后,此时只拿到了html,其本质还是内存里的字符串,并没有拿到样式表/图片等资源,只有使用innerHTML把内容添加到主文档之后,才会去获取样式表/图片等资源; 而因为B站点的html对资源的引用是相对路径,则把html添加到A站点的主文档后,html已经作为主文档的一部分,此时html资源请求路径是相对A站点的路径,而并非B站点的路径,所以会报404,资源找不到!

所以,在使用AJAX获取到B站点的html字符串后,还要使用正则式对字符串进行处理一下,这让我想起了一本经典书籍<Master Regular Expression>, 果然基本功很重要啊~

Fork me on GitHub