• 【手写简易浏览器】html parser 篇


    思路分析

    实现 html parser 主要分为词法分析和语法分析两步。

    词法分析

    词法分析需要把每一种类型的 token 识别出来,具体的类型有:

    • 开始标签,如 <div>
    • 结束标签,如 </div>
    • 注释标签,如 <!--comment-->
    • doctype 标签,如 <!doctype html>
    • text,如 aaa

    这是最外层的 token,开始标签内部还要分出属性,如 id="aaa" 这种。

    也就是有这几种情况:

    第一层判断是否包含 <,如果不包含则是 text,如果包含则再判断是哪一种,如果是开始标签,还要对其内容再取属性,直到遇到 > 就重新判断。

    语法分析

    语法分析就是对上面分出的 token 进行组装,生成 ast。

    html 的 ast 的组装主要是考虑父子关系,记录当前的 parent,然后 text、children 都设置到当前 parent 上。

    我们来用代码实现一下:

    代码实现

    词法分析

    首先,我们要把 startTag、endTag、comment、docType 还有 attribute 的正则表达式写出来:

    正则

    • 结束标签就是 </ 开头,然后 a-zA-Z0-9 和 - 出现多次,之后是 >
    const endTagReg = /^</([a-zA-Z0-9-]+)>/;
     
    • 注释标签是 <!-- 和 --> 中间夹着非 --> 字符出现任意次
    const commentReg = /^<!--[^(-->)]*-->/;
     
    • doctype 标签是 <!doctype 加非 > 字符出现多次,加 >
    const docTypeReg = /^<!doctype [^>]+>/;
     
    • attribute 是多个空格开始,加 a-zA-Z0-9 或 - 出现多次,接一个 =,之后是非 > 字符出多次
    const attributeReg = /^(?:[ ]+([a-zA-Z0-9-]+=[^>]+))/;
     
    • 开始标签是 < 开头,接 a-zA-Z0-9 和 - 出现多次,然后是属性的正则,最后是 > 结尾
    const startTagReg = /^<([a-zA-Z0-9-]+)(?:([ ]+[a-zA-Z0-9-]+=[^> ]+))*>/;
     

    分词

    之后,我们就可以基于这些正则来分词,第一层处理 < 和 text:

    function parse(html, options) {
        function advance(num) {
            html = html.slice(num);
        }
    
        while(html){
            if(html.startsWith('<')) {
                //...
            } else {
                let textEndIndex = html.indexOf('<');
                options.onText({
                    type: 'text',
                    value: html.slice(0, textEndIndex)
                });
                textEndIndex = textEndIndex === -1 ? html.length: textEndIndex;
                advance(textEndIndex);
            }
        }
    }
     

    第二层处理 <!-- 和 <!doctype 和结束标签、开始标签:

    const commentMatch = html.match(commentReg);
    if (commentMatch) {
        options.onComment({
            type: 'comment',
            value: commentMatch[0]
        })
        advance(commentMatch[0].length);
        continue;
    }
    
    const docTypeMatch = html.match(docTypeReg);
    if (docTypeMatch) {
        options.onDoctype({
            type: 'docType',
            value: docTypeMatch[0]
        });
        advance(docTypeMatch[0].length);
        continue;
    }
    
    const endTagMatch = html.match(endTagReg);
    if (endTagMatch) {
        options.onEndTag({
            type: 'tagEnd',
            value: endTagMatch[1]
        });
        advance(endTagMatch[0].length);
        continue;
    }
    
    const startTagMatch = html.match(startTagReg);
    if(startTagMatch) {    
        options.onStartTag({
            type: 'tagStart',
            value: startTagMatch[1]
        });
    
        advance(startTagMatch[1].length + 1);
        let attributeMath;
        while(attributeMath = html.match(attributeReg)) {
            options.onAttribute({
                type: 'attribute',
                value: attributeMath[1]
            });
            advance(attributeMath[0].length);
        }
        advance(1);
        continue;
    }
     

    经过词法分析,我们能拿到所有的 token:

    语法分析

    token 拆分之后,我们需要再把这些 token 组装在一起,只处理 startTag、endTag 和 text 节点。通过 currentParent 记录当前 tag。

    • startTag 创建 AST,挂到 currentParent 的 children 上,然后 currentParent 变成新创建的 tag
    • endTag 的时候把 currentParent 设置为当前 tag 的 parent
    • text 也挂到 currentParent 上
    function htmlParser(str) {
        const ast = {
            children: []
        };
        let curParent = ast;
        let prevParent = null;
        const domTree = parse(str,{
            onComment(node) {
            },
            onStartTag(token) {
                const tag = {
                    tagName: token.value,
                    attributes: [],
                    text: '',
                    children: []
                };
                curParent.children.push(tag);
                prevParent = curParent;
                curParent = tag;
            },
            onAttribute(token) {
                const [ name, value ] = token.value.split('=');
                curParent.attributes.push({
                    name,
                    value: value.replace(/^['"]/, '').replace(/['"]$/, '')
                });
            },
            onEndTag(token) {
                curParent = prevParent;
            },
            onDoctype(token) {
            },
            onText(token) {
                curParent.text = token.value;
            }
        });
        return ast.children[0];
    }
     

    我们试一下效果:

    const htmlParser = require('./htmlParser');
    
    const domTree = htmlParser(`
    <!doctype html>
    <body>
        <div>
            <!--button-->
            <button>按钮</button>
            <div id="container">
                <div class="box1">
                    <p>box1 box1 box1</p>
                </div>
                <div class="box2">
                    <p>box2 box2 box2</p>
                </div>
            </div>
        </div>
    </body>
    `);
    
    console.log(JSON.stringify(domTree, null, 4));
     

    成功生成了正确的 AST。

    总结

    这篇是简易浏览器中 html parser 的实现,少了自闭合标签的处理,就是差一个 if else,后面会补上。

    我们分析了思路并进行了实现:通过正则来进行 token 的拆分,把拆出的 token 通过回调函数暴露出去,之后进行 AST 的组装,需要记录当前的 parent,来生成父子关系正确的 AST。

    html parser 其实也是淘系前端的多年不变的面试题之一,而且 vue template compiler 还有 jsx 的 parser 也会用到类似的思路。还是有必要掌握的。希望本文能帮大家理清思路。

    代码在 github:https://github.com/QuarkGluonPlasma/tiny-browser

    转自https://mp.weixin.qq.com/s/ku6yNZqIKH9wBSGbUdhW0A

  • 相关阅读:
    StrToInt && StrToHex && IntToString && 编辑框格式 .
    DAO简介(MFC) .
    RedrawWindow, UpdateWindow,InvalidateRect 用法
    在MFC中获取窗口\视图句柄 &获取当前活动的CView .
    DAO在MFC中的应用实例 .
    捕获异常 .
    ubuntu下chm和PDF阅读器 « 小居
    QQ登陆功能的实现2 rolends1986 博客园
    【原创】说说JSON和JSONP,也许你会豁然开朗,含jQuery用例 随它去吧 博客园
    PDF Ubuntu中文
  • 原文地址:https://www.cnblogs.com/cangqinglang/p/14953272.html
Copyright © 2020-2023  润新知