Vue parse之 从template到astElement 源码详解
栏目: JavaScript · 发布时间: 6年前
内容简介:在紧张的一个星期的整理,笔者的前端小组每个人都整理了一篇文章,笔者整理了本文介绍的是Vue编译中的parse部分的源码分析,也就是从template 到 astElemnt的解析到程。从笔者的Vue编译思想详解一文中,我们已经知道编译个四个流程分别为parse、optimize、code generate、render。具体细节这里不做赘述,附上之前的一张图。
在紧张的一个星期的整理,笔者的前端小组每个人都整理了一篇文章,笔者整理了 Vue编译模版到虚拟树 的思想这一篇幅。建议读者看到这篇之前,先点击这里预习一下整个流程的思想和思路。
本文介绍的是Vue编译中的parse部分的源码分析,也就是从template 到 astElemnt的解析到程。
正文
从笔者的Vue编译思想详解一文中,我们已经知道编译个四个流程分别为parse、optimize、code generate、render。具体细节这里不做赘述,附上之前的一张图。
本文则旨在从思想落实到源代码分析,当然只是针对 parse 这一部分的。
一、 源码结构。
笔者先列出我们在看源码之前,需要先预习的一些概念和准备。
准备
1.正则
parse的最终目标是生成具有众多舒心的astElement,而这些属性有很多则摘自标签的一些属性。 如 div上的v-for、v-if、v-bind等等,最终都会变成astElement的节点属性。 这里先给个例子:
<div v-for="(item,index) in options" :key="item.id"></div> 到
{
alias: "item"
attrsList: [],
attrsMap: {"v-for": "(item,index) in options", :key: "item.id"},
children: (2) [{…}, {…}],
end: 139,
for: "options",
iterator1: "index",
key: "item.id",
parent: {type: 1, tag: "div", attrsList: Array(0), attrsMap: {…}, rawAttrsMap: {…}, …},
plain: false,
rawAttrsMap: {v-for: {…}, :key: {…}},
start: 15,
tag: "div",
type: 1,
}
复制代码
可以看到v-for的属性已经被解析和从摘除出来,存在于astElement的多个属性上面了。而 摘除 的这个功能就是出自于正则强大的力量。下面先列出一些重要的正则预热。
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/ // 重要1
const dynamicArgAttribute = /^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/ // 重要二
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const doctype = /^<!DOCTYPE [^>]+>/i
// #7298: escape - to avoid being pased as HTML comment when inlined in page
const comment = /^<!\--/
const conditionalComment = /^<!\[/
export const onRE = /^@|^v-on:/
export const dirRE = process.env.VBIND_PROP_SHORTHAND
? /^v-|^@|^:|^\./
: /^v-|^@|^:/
export const forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/
export const forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/
const stripParensRE = /^\(|\)$/g // 在v-for中去除 括号用的。
const dynamicArgRE = /^\[.*\]$/ // 判断是否为动态属性
const argRE = /:(.*)$/ // 配置 :xxx
export const bindRE = /^:|^\.|^v-bind:/ // 匹配bind的数据,如果在组件上会放入prop里面 否则放在attr里面。
const propBindRE = /^\./
const modifierRE = /\.[^.\]]+(?=[^\]]*$)/g
const slotRE = /^v-slot(:|$)|^#/
const lineBreakRE = /[\r\n]/
const whitespaceRE = /\s+/g
const invalidAttributeRE = /[\s"'<>\/=]/
复制代码
正则基础不太好的同学可以先学两篇正则基础文章,特别详细:
并且附带上两个网站,供大家学习正则。
一次性看到这么多正则是不是有点头晕目眩。不要慌,这里给大家详细讲解下比较复杂多正则。
1)获取属性多正则
attribute 和 dynamicArgAttribute 分别获取普通属性和动态属性的正则表达式。 普通属性大家一定十分属性了,这里对动态属性做下解释。
动态属性,就是key值可能会发生变动对属性,vue对写法如 v-bind:[attrName]="attrVal" 相当于控制你想要传递对参数。
我们先对 attribute 做一个详细对讲解:
const attribute = /^\s*([^\s"'<>/=]+)(?:\s*(=)\s*(?:"([^"] )"+|'([^'] )'+|([^\s"'=<>`]+)))?/ 一共分为五个分组:
- 1.([^\s"'<>/=]+) 不匹配 空格、<、>、/、= 等符号。 因为我们配置对是属性。
- 2.\s*(=)\s* 这个是 匹配 = 号,当然了空格页一并匹配了。
- 3."([^"] )" 、'([^'] )' 、([^\s"'=<>`]+) . 这三个则分别匹配三种情况 "val" 、'val' 、val。
这样的话应该比较清晰了,我们来概括下:
attribute匹配的一共是三种情况, name="xxx" name='xxx' name=xxx。 能够保证属性的所有情况都能包含进来。 需要注意的是正则处理后的数组的格式是:
['name','=','val','',''] 或者 ['name','=','','val',''] 或者 ['name','=','','','val'] 复制代码
正则的图:
而关于dynamicArgAttribute, 则是大同小异:
主要是多了 \[[^=]+\][^\s"'<>\/=]* 也就是 [name] 或者 [name]key 这类情况,附上正则详解图:
2)标签处理正则
标签主要包含开始标签 (如 <div> )和结束标签(如 </div> ),正则分别为以下两个:
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
复制代码
能够看到标签的匹配是以qnameCapture为基础的,那么这玩意又是啥呢? 其实qname就是类似于 xml:xxx 的这类带冒号的标签,所以startTagOpen是匹配 <div 或 <xml:xxx 的标签。 endTag匹配的是如 </div>或</xml:xxx> 的标签
3)处理vue的标签
export const onRE = /^@|^v-on:/ 处理绑定事件的正则 export const dirRE = process.env.VBIND_PROP_SHORTHAND ? /^v-|^@|^:|^\./ // v- | @click | :name | .stop 指令匹配 : /^v-|^@|^:/ 复制代码
for 标签比较重要,匹配也稍微复杂点,这里做个详解:
export const forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/ export const forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/ 复制代码
首先申明这里的正则是依赖于attribute正则的,我们会拿到v-for里面的内容,举个例子 v-for="item in options" ,我们最终会处理成一个map的形式,大致如下:
const element = {
attrMap: {
'v-for':'item in options',
...
}
}
复制代码
先看 forAliasRE 的分组,一共两个分组分别([\s\S] ?)和([\s\S] ) 会分别匹配 item 和 options。 但是 in或of之前内容可能是比较复杂的,如(value,key) 或者(item,index)等,这个时候就是forIteratorRE开始起作用了。 它一共两个分组都是([^,}]]*),其实就是拿到alias的最后两个参数,大家都知道对于Object我们是可以这么做的:
<div v-for="(value,key,index)"> 复制代码
而数组则是为了获取key 和index的。最终会放在astElement的iterator1 和 iterator2。
好了关于正则就说这么多了,具体的情况还是得自己去看看源码的。
2.源码结构
依然是在开始讲源码前,先大致介绍下源码的结构。先贴个代码出来
function parse() {
模块一:初始化需要的方法
模块二: 初始化所有标记
模块三: 开始识别并创建 astElement 树。
}
复制代码
模块一大致是:
platformIsPreTag = options.isPreTag || no //判断是否为 pre 标签
platformMustUseProp = options.mustUseProp || no // 判断某个属性是否是某个标签的必要属性,如selected 对于option
platformGetTagNamespace = options.getTagNamespace || no // 判断是否为 svg or math标签 对函数
const isReservedTag = options.isReservedTag || no // 判断是否为该平台对标签,目前vue源码只有 web 和weex两个平台。
maybeComponent = (el: ASTElement) => !!el.component || !isReservedTag(el.tag) //是否可能为组件
transforms = pluckModuleFunction(options.modules, 'transformNode') // 数组,成员是方法, 用途是摘取 staticStyle styleBinding staticClass classBinding
preTransforms = pluckModuleFunction(options.modules, 'preTransformNode') // ??
postTransforms = pluckModuleFunction(options.modules, 'postTransformNode') // ??
delimiters = options.delimiters // express标志
function closeElement() {...} // 处理astElement对结尾函数
function trimEndingWhitespace() {...} // 处理尾部空格
function checkRootConstraints() {...} // 检查root标签对合格性
复制代码
模块二大致为:
const stack = [] // 配合使用的栈 主要目的是为了完成树状结构。 let root // 根节点记录,树顶 let currentParent // 当前父节点 let inVPre = false // 标记是否在v-pre节点 当中 let inPre = false // 是否在pre标签当中 let warned = false 复制代码
模块三大致为:
parseHTML(template,options) 复制代码
options 是关键,包括很多平台配置和 传入的四个处理方法。大致如下:
options = {
warn,
expectHTML: options.expectHTML, // 是否期望和浏览器器保证一致。
isUnaryTag: options.isUnaryTag, // 是否为一元标签的判断函数
canBeLeftOpenTag: options.canBeLeftOpenTag, // 可以直接进行闭合的标签
shouldDecodeNewlines: options.shouldDecodeNewlines,
shouldDecodeNewlinesForHref: options.shouldDecodeNewlinesForHref,
shouldKeepComment: options.comments,
outputSourceRange: options.outputSourceRange,
// 这里分开,上面是平台配置、下面是处理函数。
start, (1)
end, (2)
chars, (3)
commend (4)
}
复制代码
笔者之前的parse思想,已经介绍过两个处理函数start和end了,一个是创建astElement另一个是建立父子关系,其中细节会在下文中,详细介绍,这也是本文的重点。切记这四个函数至关重要,下面会用代号讲解。
二、各模块重点功能。
Vue的html解析并非一步到位,先来介绍一些重点的函数功能
1.parseHTML函数功能。
(1)解析开始标签和 处理属性,生成初始化match。代码如下:
/**
* 创建match数据结构
* 初始化的状态
* 只有
* tagName
* attrs
* attrs自己是个数组 也就是 正则达到的效果。。
* start
* end
*/
function parseStartTag () {
const start = html.match(startTagOpen)
if (start) {
const match = { // 匹配startTag的数据结构
tagName: start[1],
attrs: [],
start: index
}
advance(start[0].length)
let end, attr
// 取属性值
while (!(end = html.match(startTagClose)) && (attr = html.match(dynamicArgAttribute) || html.match(attribute))) {
attr.start = index
advance(attr[0].length)
attr.end = index
match.attrs.push(attr)
}
if (end) {
match.unarySlash = end[1] // 是否为 一元标记 直接闭合
advance(end[0].length)
match.end = index
return match
}
}
}
复制代码
parseStartTag的目标是比较原始的,获得类似于
const match = { // 匹配startTag的数据结构
tagName: 'div',
attrs: [
{ 'id="xxx"','id','=','xxx' },
...
],
start: index,
end: xxx
}
复制代码
match大致可以概括为获取标签、属性和位置信息。并将此传递给下个函数。
(2)handleStartTag处理parseStartTag传递过来的match。
// parseStartTag 拿到的是 match
function handleStartTag (match) {
const tagName = match.tagName
const unarySlash = match.unarySlash
if (expectHTML) { // 是否期望和浏览器的解析保持一致。
if (lastTag === 'p' && isNonPhrasingTag(tagName)) {
parseEndTag(lastTag)
}
if (canBeLeftOpenTag(tagName) && lastTag === tagName) {
parseEndTag(tagName)
}
}
const unary = isUnaryTag(tagName) || !!unarySlash // 一元判断
const l = match.attrs.length
const attrs = new Array(l)
for (let i = 0; i < l; i++) { // 将attrs的 数组模式变成 { name:'xx',value:'xxx' }
const args = match.attrs[i]
const value = args[3] || args[4] || args[5] || ''
const shouldDecodeNewlines = tagName === 'a' && args[1] === 'href'
? options.shouldDecodeNewlinesForHref
: options.shouldDecodeNewlines
attrs[i] = {
name: args[1],
value: decodeAttr(value, shouldDecodeNewlines)
}
if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
attrs[i].start = args.start + args[0].match(/^\s*/).length
attrs[i].end = args.end
}
}
if (!unary) { // 非一元标签处理方式
stack.push({ tag: tagName, lowerCasedTag: tagName.toLowerCase(), attrs: attrs, start: match.start, end: match.end })
lastTag = tagName
}
if (options.start) {
options.start(tagName, attrs, unary, match.start, match.end)
}
}
复制代码
handleStartTag的本身效果其实非常简单直接,就是吧match的attrs重新处理,因为之前是数组结构,在这里他们将所有的数组式attr变成一个对象,流程大致如下:
从这样:
attrs: [
{ 'id="xxx"','id','=','xxx' },
...
],
复制代码
变成这样:
attrs: [
{name='id',value='xxx' },
...
],
复制代码
那么其实还有些特殊处理 expectHTML 和 一元标签 。
expectHTML 是为了处理一些异常情况。如 p标签的内部出现div等等、浏览器会特殊处理的情况,而Vue会尽量和浏览器保持一致。具体参考p标签标准。
最后handleStartTag会调用 从parse传递的start(1)函数来做处理,start函数会在下文中有详细的讲解。
(3) parseEndTag
parseEndTag本身的功能特别简单就是直接调用options传递进来的end函数,但是我们观看源码的时候会发现源码还蛮长的。
function parseEndTag (tagName, start, end) {
let pos, lowerCasedTagName
if (start == null) start = index
if (end == null) end = index
// Find the closest opened tag of the same type
if (tagName) {
lowerCasedTagName = tagName.toLowerCase()
for (pos = stack.length - 1; pos >= 0; pos--) {
if (stack[pos].lowerCasedTag === lowerCasedTagName) {
break
}
}
} else {
// If no tag name is provided, clean shop
pos = 0
}
if (pos >= 0) {
// Close all the open elements, up the stack
for (let i = stack.length - 1; i >= pos; i--) {
if (process.env.NODE_ENV !== 'production' &&
(i > pos || !tagName) &&
options.warn
) {
options.warn(
`tag <${stack[i].tag}> has no matching end tag.`,
{ start: stack[i].start, end: stack[i].end }
)
}
if (options.end) {
options.end(stack[i].tag, start, end)
}
}
// Remove the open elements from the stack
stack.length = pos
lastTag = pos && stack[pos - 1].tag
} else if (lowerCasedTagName === 'br') {
if (options.start) {
options.start(tagName, [], true, start, end)
}
} else if (lowerCasedTagName === 'p') {
if (options.start) {
options.start(tagName, [], false, start, end)
}
if (options.end) {
options.end(tagName, start, end)
}
}
}
}
复制代码
看起来还蛮长的,其实主要都是去执行options.end, Vue的源码有很多的代码量都是在处理特殊情况,所以看起来很臃肿。这个函数的特殊情况主要有两种:
- 1.编写者失误,有标签没有闭合。会直接一次性和检测的闭合标签一起进入options.end。 如:
<div>
<span>
<p>
</div>
复制代码
在处理div的标签时,根据pos的位置,将pos之前的所有标签和匹配到的标签都会一起遍历的去执行end函数。
-
- p标签和br标签
可能会遇到 </p> 和 </br> 标签 这个时候 p标签会走跟浏览器自动补全效果,先start再end。 而br则是一元标签,直接进入end效果。
2.start、end、comment、chars四大函数。
1)start函数
start函数非常长。这里截取重点部分
start() {
...
let element: ASTElement = createASTElement(tag, attrs, currentParent)
...
if (!inVPre) {
processPre(element)
if (element.pre) {
inVPre = true
}
}
if (platformIsPreTag(element.tag)) {
inPre = true
}
if (inVPre) {
processRawAttrs(element)
} else if (!element.processed) {
// structural directives
processFor(element)
processIf(element)
processOnce(element)
}
if (!root) {
root = element
if (process.env.NODE_ENV !== 'production') {
checkRootConstraints(root)
}
}
if (!unary) {
currentParent = element
stack.push(element)
} else {
closeElement(element)
}
}
复制代码
- 1).创建astElement节点。
结构如下:
{
type: 1,
tag,
attrsList: attrs,
attrsMap: makeAttrsMap(attrs),
rawAttrsMap: {},
parent,
children: []
}
复制代码
-
2)处理属性 当然在这里只是处理部分属性,且分为两种情况:
(1)pre模式 直接摘取所有属性
(2)普通模式 分别处理processFor(element) 、processIf(element) 、 processOnce(element)。
2)end函数
end函数非常短
end (tag, start, end) {
const element = stack[stack.length - 1]
// pop stack
stack.length -= 1
currentParent = stack[stack.length - 1]
if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
element.end = end
}
closeElement(element)
},
复制代码
end函数第一件事就是取出当前栈的父元素赋值给currentParent,然后执行closeElement,为的就是能够创建完整的树节点关系。 所以closeElement才是end函数的重点。
下面详细解释下closeElement
function closeElement (element) {
trimEndingWhitespace(element) // 去除 未部对空格元素
if (!inVPre && !element.processed) {
element = processElement(element, options) // 处理Vue相关对一些属性关系
}
// tree management
if (!stack.length && element !== root) {
// allow root elements with v-if, v-else-if and v-else
if (root.if && (element.elseif || element.else)) {
if (process.env.NODE_ENV !== 'production') {
checkRootConstraints(element)
}
addIfCondition(root, { // 处理root到 条件展示
exp: element.elseif,
block: element
})
} else if (process.env.NODE_ENV !== 'production') {
warnOnce(
`Component template should contain exactly one root element. ` +
`If you are using v-if on multiple elements, ` +
`use v-else-if to chain them instead.`,
{ start: element.start }
)
}
}
if (currentParent && !element.forbidden) {
if (element.elseif || element.else) { // 处理 elseif else 块级
processIfConditions(element, currentParent)
} else {
if (element.slotScope) { // 处理slot, 将生成的各个slot的astElement 用对象展示出来。
// scoped slot
// keep it in the children list so that v-else(-if) conditions can
// find it as the prev node.
const name = element.slotTarget || '"default"'
;(currentParent.scopedSlots || (currentParent.scopedSlots = {}))[name] = element
}
currentParent.children.push(element)
element.parent = currentParent
}
}
// final children cleanup
// filter out scoped slots
element.children = element.children.filter(c => !(c: any).slotScope)
// remove trailing whitespace node again
trimEndingWhitespace(element)
// check pre state
if (element.pre) {
inVPre = false
}
if (platformIsPreTag(element.tag)) {
inPre = false
}
// apply post-transforms
for (let i = 0; i < postTransforms.length; i++) {
postTransforms[i](element, options)
}
}
复制代码
主要是做了五个操作:
- 1.processElement。
processElement是closeElement非常重要的一个处理函数。先把代码贴出来。
export function processElement (
element: ASTElement,
options: CompilerOptions
) {
processKey(element)
// determine whether this is a plain element after
// removing structural attributes
element.plain = (
!element.key &&
!element.scopedSlots &&
!element.attrsList.length
)
processRef(element)
processSlotContent(element)
processSlotOutlet(element)
processComponent(element)
for (let i = 0; i < transforms.length; i++) {
element = transforms[i](element, options) || element
}
processAttrs(element)
return element
}
复制代码
可以看到主要是processKey、processRef、processSlotContent、processSlotOutlet、processComponent、processAttrs和最后一个遍历的执行的transforms。
processSlotContent是处理展示在组件内部的slot,但是在这个地方只是简单的将给el添加两个属性作用域插槽的slotScope和 slotTarget,也就是目标slot。 processSlotOutlet,则是简单的摘取 slot元素上面的name,并赋值给slotName。 processComponent 并不是处理component,而是摘取动态组件的is属性。 processAttrs是获取所有的属性和动态属性。
transforms是处理class和style的函数数组。这里不做赘述了。
- 2.添加elseif 或else的block。
最终生成的的ifConditions块级的格式大致为:
[
{
exp:'showToast',
block: castElement1
},
{
exp:'showOther',
block: castElement2
},
{
exp: undefined,
block: castElement3
}
]
复制代码
这里会将条件展示处理成一个数组,exp存放所有的展示条件,如果是else 则为undefined。
- 3.处理slot,将各个slot对号入座到一个对象scopedSlots。
processElement完成的slotTarget的赋值,这里则是将所有的slot创建的astElement以对象的形式赋值给currentParent的scopedSlots。以便后期组件内部实例话的时候可以方便去使用vm. slot的初始化。
- 4.处理树到父子关系,element.parent = currentParent。
- 5.postTransforms
不做具体介绍了,感兴趣的同学自己去研究下吧。
3)chars函数
chars(){
...
const children = currentParent.children
...
if (!inVPre && text !== ' ' && (res = parseText(text, delimiters))) {
child = {
type: 2,
expression: res.expression,
tokens: res.tokens,
text
}
} else if (text !== ' ' || !children.length || children[children.length - 1].text !== ' ') {
child = {
type: 3,
text
}
}
}
复制代码
chars主要处理两中文本情况,静态文本和表达式,举个例子:
<div>name</div> 复制代码
name就是静态文本,创建的type为3.
<div>{{name}}</div>
复制代码
而在这个里面name则是表达式,创建的节点type为2。
做个总结就是:普通tag的type为1,纯文本type为2,表达式type为3。
4)comment函数比较简单
comment (text: string, start, end) {
// adding anyting as a sibling to the root node is forbidden
// comments should still be allowed, but ignored
if (currentParent) {
const child: ASTText = {
type: 3,
text,
isComment: true
}
if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
child.start = start
child.end = end
}
currentParent.children.push(child)
}
}
复制代码
也是纯文本,只是节点加上了一个isComment:true的标志。
三、整体流程总结。
普通标签处理流程描述
- 1.识别开始标签,生成匹配结构match。
const match = { // 匹配startTag的数据结构
tagName: 'div',
attrs: [
{ 'id="xxx"','id','=','xxx' },
...
],
start: index,
end: xxx
}
复制代码
- 2.处理attrs,将数组处理成 {name:'xxx',value:'xxx'}
- 3.生成astElement,处理for,if和once的标签。
- 4.识别结束标签,将没有闭合标签的元素一起处理。
- 5.建立父子关系,最后再对astElement做所有跟Vue 属性相关对处理。slot、component等等。
文本或表达式的处理流程描述。
- 1、截取符号<之前的字符串,这里一定是所有的匹配规则都没有匹配上,只可能是文本了。
- 2、使用chars函数处理该字符串。
- 3、判断字符串是否含有delimiters,默认也就是${},有的话创建type为2的节点,否则type为3.
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:- JDK SPI源码详解
- 【zookeeper源码】启动流程详解
- 详解RunLoop之源码分析
- 详解CopyOnWrite容器及其源码
- React Scheduler 源码详解(1)
- React Scheduler 源码详解(2)
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Web Applications (Hacking Exposed)
Joel Scambray、Mike Shema / McGraw-Hill Osborne Media / 2002-06-19 / USD 49.99
Get in-depth coverage of Web application platforms and their vulnerabilities, presented the same popular format as the international bestseller, Hacking Exposed. Covering hacking scenarios across diff......一起来看看 《Web Applications (Hacking Exposed)》 这本书的介绍吧!
HTML 压缩/解压工具
在线压缩/解压 HTML 代码
HTML 编码/解码
HTML 编码/解码