1. 引言
微信小程序里目前原生的RichText组件只支持部分HTML标签的显示。但是,在实际开发中,我们可能需要渲染更为复杂的HTML内容。本文将通过一个实际案例,介绍如何在微信小程序中渲染HTML内容。
2. 业务需求与方案设计
2.1 业务需求
在我们的项目中,有一个需求是显示来自服务器的HTML格式的富文本内容,如图所示:
![需求截图](https://s1.ax1x.com/2020/10/28/BYYJFO.png)
为了实现这个需求,我们做了如下的方案设计:
2.2 方案设计
我们需要将HTML内容转化成小程序所支持的nodes列表来进行渲染。我们选择了第三方库[htmlparser](https://github.com/blowsie/Pure-JavaScript-HTML5-Parser)来将HTML字符串转化成AST,再通过遍历AST生成小程序所支持的nodes列表。
3. HTML字符串转成AST
3.1 安装htmlparser
在项目中安装htmlparser:
npm install htmlparser --save
3.2 使用htmlparser解析HTML字符串
使用htmlparser解析HTML字符串会返回一个AST,具体使用方式如下:
const { parse } = require('htmlparser');
/**
* @description 将HTML字符串转成AST
* @param {String} html HTML字符串
* @returns {Object} AST
*/
function html2ast(html) {
const handler = new htmlparser.DomHandler(function (error, dom) {
if (error) {
console.error('解析HTML字符串出错', error);
throw error;
}
return dom;
});
const parser = new htmlparser.Parser(handler);
parser.parseComplete(html);
const ast = handler.dom;
return ast;
}
4. AST转成nodes列表
4.1 AST的遍历
我们需要按照一定的顺序遍历AST,将AST转成nodes列表。遍历AST的方式有以下三种:
1. 深度优先遍历
2. 广度优先遍历
3. 前序遍历
由于深度优先遍历和广度优先遍历都需要借助队列和栈等数据结构,而前序遍历则不需要,且符合我们的视图生成需求,所以我们选择前序遍历AST。
4.2 AST节点类型与属性的处理
不同类型的AST节点对应着微信小程序中不同的节点类型和属性,我们需要将AST的节点类型和属性转成相应的小程序节点类型和属性。
以下是我们实现的AST节点类型转小程序节点类型和属性的映射表:
const Tag2Node = {
'html': 'view',
'head': 'view',
'body': 'view',
'base': 'aria-component',
'link': 'aria-component',
'meta': 'aria-component',
'style': 'view',
'script': 'view',
'noscript': 'view',
'template': 'view',
'slot': 'view',
'img': 'image',
'a': 'navigator',
'button': 'button',
'input': 'input',
'textarea': 'textarea',
'select': 'picker',
'option': 'picker-view-column',
'form': 'form',
'label': 'label',
'fieldset': 'view',
'legend': 'view',
'datalist': 'picker',
'optgroup': 'view',
'output': 'view',
'progress': 'progress',
'meter': 'view',
'details': 'view',
'summary': 'view',
'menu': 'view',
'menuitem': 'view',
'dialog': 'view',
'h1': 'view',
'h2': 'view',
'h3': 'view',
'h4': 'view',
'h5': 'view',
'h6': 'view',
'p': 'view',
'hr': 'view',
'pre': 'view',
'blockquote': 'view',
'ol': 'view',
'ul': 'view',
'li': 'view',
'dl': 'view',
'dt': 'view',
'figure': 'view',
'figcaption': 'view',
'div': 'view',
'caption': 'view',
'thead': 'view',
'tbody': 'view',
'tfoot': 'view',
'tr': 'view',
'th': 'view',
'td': 'view',
'col': 'view',
'colgroup': 'view'
};
function getTagByNodeType(nodeType) {
if (nodeType === 'text') {
return 'text';
} else if (nodeType === 'comment') {
return 'comment';
} else {
return Tag2Node[nodeType] || 'view';
}
}
function getAttrsByNode(node) {
const attrs = {};
for (let key in node.attribs) {
if (key === 'class') {
attrs['class'] = node.attribs[key];
} else if (key === 'style') {
attrs['style'] = node.attribs[key].replace(/ /g, '').replace(/;$/g, '');
} else if (/^data-/.test(key)) {
attrs[key] = node.attribs[key];
} else if (/^on/.test(key)) {
attrs[key] = node.attribs[key];
} else if (/^id/.test(key)) {
attrs[key] = node.attribs[key];
} else if (/^aria-/.test(key)) {
attrs[key] = node.attribs[key];
}
}
return attrs;
}
4.3 AST节点转节点对象
将AST节点转成节点对象的代码如下:
function nodeToObj(node) {
const tag = getTagByNodeType(node.type);
if (tag === 'view') {
return null;
}
const attrs = getAttrsByNode(node);
const children = [];
const obj = {
tag,
attrs,
children
};
if (tag === 'text') {
obj['text'] = node.data.trim();
}
if (node.children && node.children.length > 0) {
node.children.forEach(childNode => {
const childObj = nodeToObj(childNode);
if (childObj) {
children.push(childObj);
}
});
}
return obj;
}
4.4 AST遍历与节点转换
最终的代码如下:
function traverseAST(ast) {
const nodes = [];
const stack = [];
ast.forEach(node => {
stack.unshift(node);
});
while (stack.length) {
const node = stack.shift();
const obj = nodeToObj(node);
if (obj) {
if (nodes.length > 0) {
const parent = nodes[nodes.length - 1];
parent.children.push(obj);
}
nodes.push(obj);
} else {
if (nodes.length > 0) {
const lastNode = nodes[nodes.length - 1];
if (lastNode.tag === getTagByNodeType(node.type)) {
nodes.pop();
} else {
throw new Error(`节点类型不匹配,期望类型为${lastNode.tag}, 实际类型为${node.type}`);
}
}
}
if (node.children && node.children.length > 0) {
node.children.forEach(childNode => {
stack.unshift(childNode);
});
}
}
return nodes;
}
5. 将nodes列表渲染到视图中
5.1 创建template和渲染数据
我们需要在视图中创建一个template用来渲染nodes列表,具体代码如下:
<template name="customRichText">
<block wx:if="{{nodes.length > 0}}">
<block wx:for="{{nodes}}" wx:key="{{index}}">
<block wx:if="{{item.tag === 'text'}}">
<text>{{item.text}}</text>
</block>
<block wx:else="{{item.tag !== 'text'}}">
<{{item.tag}}
wx:for="{{item.attrs}}"
wx:key="{{index}}"
wx:if="{{value}}"
{{key}}="{{value}}"
></{{item.tag}}>
<template is="customRichText" wx:if="{{item.children}}" data="{{nodes: item.children}}"></template>
</block>
</block>
</block>
</template>
渲染数据是一个nodes数组。
5.2 渲染数据转换
我们还需要将AST转化的nodes数组转成渲染数据格式,用于渲染到视图中,具体代码如下:
function nodes2data(nodes) {
const data = {};
data['nodes'] = nodes;
return data;
}
5.3 完整渲染代码
将渲染模板和渲染数据结合,并在视图中引入即可。
完整代码如下:
const { parse } = require('htmlparser');
/**
* @description 将HTML字符串转成AST
* @param {String} html HTML字符串
* @returns {Object} AST
*/
function html2ast(html) {
const handler = new htmlparser.DomHandler(function (error, dom) {
if (error) {
console.error('解析HTML字符串出错', error);
throw error;
}
return dom;
});
const parser = new htmlparser.Parser(handler);
parser.parseComplete(html);
const ast = handler.dom;
return ast;
}
const Tag2Node = {
'html': 'view',
'head': 'view',
'body': 'view',
'base': 'aria-component',
'link': 'aria-component',
'meta': 'aria-component',
'style': 'view',
'script': 'view',
'noscript': 'view',
'template': 'view',
'slot': 'view',
'img': 'image',
'a': 'navigator',
'button': 'button',
'input': 'input',
'textarea': 'textarea',
'select': 'picker',
'option': 'picker-view-column',
'form': 'form',
'label': 'label',
'fieldset': 'view',
'legend': 'view',
'datalist': 'picker',
'optgroup': 'view',
'output': 'view',
'progress': 'progress',
'meter': 'view',
'details': 'view',
'summary': 'view',
'menu': 'view',
'menuitem': 'view',
'dialog': 'view',
'h1': 'view',
'h2': 'view',
'h3': 'view',
'h4': 'view',
'h5': 'view',
'h6': 'view',
'p': 'view',
'hr': 'view',
'pre': 'view',
'blockquote': 'view',
'ol': 'view',
'ul': 'view',
'li': 'view',
'dl': 'view',
'dt': 'view',
'figure': 'view',
'figcaption': 'view',
'div': 'view',
'caption': 'view',
'thead': 'view',
'tbody': 'view',
'tfoot': 'view',
'tr': 'view',
'th': 'view',
'td': 'view',
'col': 'view',
'colgroup': 'view'
};
function getTagByNodeType(nodeType) {
if (nodeType === 'text') {
return 'text';
} else if (nodeType === 'comment') {
return 'comment';
} else {
return Tag2Node[nodeType] || 'view';
}
}
function getAttrsByNode(node) {
const attrs = {};
for (let key in node.attribs) {
if (key === 'class') {
attrs['class'] = node.attribs[key];
} else if (key === 'style') {
attrs['style'] = node.attribs[key].replace(/ /g, '').replace(/;$/g, '');
} else if (/^data-/.test(key)) {
attrs[key] = node.attribs[key];
} else if (/^on/.test(key)) {
attrs[key] = node.attribs[key];
} else if (/^id/.test(key)) {
attrs[key] = node.attribs[key];
} else if (/^aria-/.test(key)) {
attrs[key] = node.attribs[key];
}
}
return attrs;
}
function nodeToObj(node) {
const tag = getTagByNodeType(node.type);
if (tag === 'view') {
return null;
}
const attrs = getAttrsByNode(node);
const children = [];
const obj = {
tag,
attrs,
children
};
if (tag === 'text') {
obj['text'] = node.data.trim();
}
if (node.children && node.children.length > 0) {
node.children.forEach(childNode => {
const childObj = nodeToObj(childNode);
if (childObj) {
children.push(childObj);
}
});
}
return obj;
}
function traverseAST(ast) {
const nodes = [];
const stack = [];
ast.forEach(node => {
stack.unshift(node);
});
while (stack.length) {
const node = stack.shift();
const obj = nodeToObj(node);
if (obj) {
if (nodes.length > 0) {
const parent = nodes[nodes.length - 1];
parent.children.push(obj);
}
nodes.push(obj);
} else {
if (nodes.length > 0) {
const lastNode = nodes[nodes.length - 1];
if (lastNode.tag === getTagByNodeType(node.type)) {
nodes.pop();
} else {
throw new Error(`节点类型不匹配,期望类型为${lastNode.tag}, 实际类型为${node.type}`);
}
}
}
if (node.children && node.children.length > 0) {
node.children.forEach(childNode => {
stack.unshift(childNode);
});
}
}
return nodes;
}
function nodes2data(nodes) {
const data = {};
data['nodes'] = nodes;
return data;
}
module.exports = {
html2ast,
traverseAST,
nodes2data
};
使用的时候,只需要将HTML字符串转成AST,然后再通过AST转成需渲染的nodes数组,最后将nodes数组转成渲染数据,然后在视图上引入即可。