大部分Web应用的富文本内容都是以HTML字符串的形式存储的,经过HTML文档去展现HTML内容天然没有问题。可是,在微信小程序(下文简称为「小程序」)中,应当如何渲染这部份内容呢?javascript
小程序刚上线那会儿,是没法直接渲染HTML内容的,因而就诞生了一个叫作「wxParse」的库。它的原理就是把HTML代码解析成树结构的数据,再经过小程序的模板把该数据渲染出来。html
后来,小程序增长了「rich-text」组件用于展现富文本内容。然而,这个组件存在一个极大的限制:组件内屏蔽了全部节点的事件。也就是说,在该组件内,连「预览图片」这样一个简单的功能都没法实现。java
再后来,小程序容许经过「web-view」组件嵌套网页,经过网页展现HTML内容是兼容性最好的解决方案了。然而,由于要多加载一个页面,性能是较差的。node
基于用户体验和功能交互上的考虑,咱们抛弃了「rich-text」和「web-view」这两个原生组件,选择了「wxParse」。然而,用着用着却发现,「wxParse」也不能很好地知足须要:android
此外,围观一下「wxParse」的代码仓库能够发现,它已经两年没有迭代了。因此就萌生了基于「WePY」的组件模式从新写一个富文本组件的想法,其成果就是「WePY HTML」项目。git
首先仍然是要把HTML字符串解析为树结构的数据,我采用的是「特殊字符分隔法」。HTML中的特殊字符是「<」和「>」,前者为开始符,后者为结束符。github
正以下图所示:web
为了造成树结构,解析过程当中要维护一个上下文节点(默认为根节点):算法
过程正以下面的表格所示:小程序
上下文(解析前) | 解析内容 | 上下文(解析后) |
---|---|---|
根节点 | <div class="content"> | div |
div | <p style="text-indent: 2em;"> | p |
p | Hello world | p |
p | </p> | div |
div | </div> | 根节点 |
通过上述流程,HTML字符串就被解析为节点树了。
把上述算法与其余相似的解析算法进行对比(性能以「解析10000长度的HTML代码」进行测定):
对比项 | 本组件算法 | wxParse | parse5 |
---|---|---|---|
性能 | 3~6ms | 20ms左右 | 20ms左右 |
容错性 | 差 | 通常 | 强 |
文件大小(未压缩) | 6kb | 22kb | 接近400kb |
可见,在不考虑容错性(产生错误的结果,而非抛出异常)的状况下,本组件的算法与其他二者相比有压倒性的优点,符合小程序「小而快」的须要。而通常状况下,富文本编辑器所生成的代码也不会出现语法错误。所以,即便容错性较差,问题也不大(但这是须要改进的)。
树结构的渲染,必然会涉及到子节点的递归处理。然而,小程序的模板并不支持递归,这下仿佛掉入了一个大坑。
看了一下「wxParse」模板的实现,它采用简单粗暴的方式解决这个问题:经过13个长得几乎如出一辙的模板进行嵌套调用(1调用2,2调用3,……,12调用13),也就是说最多能够支持12次嵌套。通常来讲,这个深度也足够了。
因为「WePY」框架自己是有构建机制的,因此没必要手写十来个几乎如出一辙的模板,经过一个构建的插件去生成便可。
如下为须要重复嵌套的模板(精简过),在其代码的开始前和结束后分别插入特殊注释进行标识,并在须要嵌入下一层模板的地方以另外一段特殊注释(「<!-- next template -->」)标识:
<!-- wepyhtml-repeat start -->
<template name="wepyhtml-0">
<block wx:if="{{ content }}" wx:for="{{ content }}">
<block wx:if="{{ item.type === 'node' }}">
<view class="wepyhtml-tag-{{ item.name }}">
<!-- next template -->
</view>
</block>
<block wx:else>{{ item.text }}</block>
</block>
</template>
<!-- wepyhtml-repeat end -->
复制代码
如下是对应的构建代码(须要安装「wepy-plugin-replace」):
// wepy.config.js
{
plugins: {
replace: {
filter: /\.wxml$/,
config: {
find: /<\!-- wepyhtml-repeat start -->([\W\w]+?)<\!-- wepyhtml-repeat end -->/,
replace(match, tpl) {
let result = '';
// 反正不要钱,直接写个20层嵌套
for (let i = 0; i <= 20; i++) {
result += '\n' + tpl
.replace('wepyhtml-0', 'wepyhtml-' + i)
.replace(/<\!-- next template -->/g, () => {
return i === 20 ?
'' :
`<template is="wepyhtml-${ i + 1 }" wx:if="{{ item.children }}" data="{{ content: item.children"></template>`;
});
}
return result;
}
}
}
}
}
复制代码
然而,运行起来后发现,第二层及更深层级的节点都没有渲染出来,说明嵌套失败了。再看一下dist目录下生成的wxml文件能够发现,变量名与组件源代码的并不相同:
<block wx:if="{{ $htmlContent$wepyHtml$content }}" wx:for="{{ $htmlContent$wepyHtml$content }}">
复制代码
「WePY」在生成组件代码时,为了不组件数据与页面数据的变量名冲突,会根据必定的规则给组件的变量名增长前缀(如上面代码中的「wepyHtml$」)。因此在生成嵌套模板时,也必须使用带前缀的变量名。
先在组件代码中增长一个变量「thisIsMe」用于识别前缀:
<!-- wepyhtml-repeat start -->
<template name="wepyhtml-0">
{{ thisIsMe }}
<block wx:if="{{ content }}" wx:for="{{ content }}">
<block wx:if="{{ item.type === 'node' }}">
<view class="wepyhtml-tag-{{ item.name }}">
<!-- next template -->
</view>
</block>
<block wx:else>{{ item.text }}</block>
</block>
</template>
<!-- wepyhtml-repeat end -->
复制代码
而后修改构建代码:
replace(match, tpl) {
let result = '';
let prefix = '';
// 匹配 thisIsMe 的前缀
tpl = tpl.replace(/\{\{\s*(\$.*?\$)thisIsMe\s*\}\}/, (match, p) => {
prefix = p;
return '';
});
for (let i = 0; i <= 20; i++) {
result += '\n' + tpl
.replace('wepyhtml-0', 'wepyhtml-' + i)
.replace(/<\!-- next template -->/g, () => {
return i === 20 ?
'' :
`<template is="wepyhtml-${ i + 1 }" wx:if="{{ item.children }}" data="{{ ${ prefix }content: item.children }}"></template>`;
});
}
return result;
}
复制代码
至此,渲染问题就解决了。
为了节省流量和提升加载速度,展现富文本内容时,通常都会按照所需尺寸对里面的图片进行缩小,点击小图进行预览时才展现原图。这主要涉及节点属性的修改:
为了实现这个需求,本组件在解析节点时提供了一个钩子(onNodeCreate):
onNodeCreate(name, attrs) {
if (name === 'img') {
attrs['data-src'] = attrs.src;
// 预览图数组
this.previewImgs.push(attrs.src);
// 缩图
attrs.src = resizeImg(attrs.src, 640);
}
}
复制代码
对应的模板和事件处理逻辑以下:
<template name="wepyhtml-img">
<image class="wepyhtml-tag-img" mode="widthFix" src="{{ elem.attrs.src }}" data-src="{{ elem.attrs['data-src'] || elem.attrs.src }}" @tap="imgTap"></image>
</template>
复制代码
// 点击小图看大图
imgTap(e) {
wepy.previewImage({
current: e.currentTarget.dataset.src,
urls: this.previewImgs
});
}
复制代码
在小程序中,video组件的层级是较高的(且没法下降)。若是页面设计上存在着可能挡住视频的元素,处理起来就须要一些技巧了:
相关代码以下:
<template name="wepyhtml-video">
<view class="wepyhtml-tag-video" @tap="videoTap" data-nodeid="{{ elem.nodeId }}">
<!-- 视频封面 -->
<image class="wepyhtml-tag-img wepyhtml-tag-video__poster" mode="widthFix" src="{{ elem.attrs.poster }}"></image>
<!-- 播放图标 -->
<image class="wepyhtml-tag-img wepyhtml-tag-video__play" src="./imgs/icon-play.png"></image>
<!-- 视频组件 -->
<video style="display: none;" src="{{ elem.attrs.src }}" id="wepyhtml-video-{{ elem.nodeId }}" @fullscreenchange="videoFullscreenChange" @play="videoPlay"></video>
</view>
</template>
复制代码
{
// 点击封面图,播放视频
videoTap(e) {
const nodeId = e.currentTarget.dataset.nodeid;
const context = wepy.createVideoContext('wepyhtml-video-' + nodeId);
context.play();
// 在安卓微信下,若是视频不可见,则调用play()也没法播放
// 须要再调用全屏方法
if (wepy.getSystemInfoSync().platform === 'android') {
context.requestFullScreen();
}
},
// 视频层级较高,为防止遮挡其余特殊定位元素,形成界面异常,
// 强制全屏播放
videoPlay(e) {
wepy.createVideoContext(e.currentTarget.id).requestFullScreen();
},
// 退出全屏则暂停
videoFullscreenChange(e) {
if (!e.detail.fullScreen) {
wepy.createVideoContext(e.currentTarget.id).pause();
}
}
}
复制代码
开源 最后贴一下「WePY HTML」的项目仓库: github.com/beiliao-web… ,具体使用方法见项目内的 README 。若是你在使用过程当中遇到了问题,或者是有好的建议和意见,均可以在 Issues 中提出。
(本文同时发表于做者我的博客 mrluo.life/article/det… )