HTMLStringParser:自己撸一个Virtual DOM之前

广告位招租
扫码页面底部二维码联系

在我写完《Virtual DOM原理浅易详解》之后,我打算把Virtual DOM的体系拆解开。其中非常重要的一点,是我打算做一个HTML的解析器,在通过fetch抓取到某个网页之后,可以通过这个解析器,快速得到自己想要的数据。而这一部分,是Virtual DOM整个知识体系的一部分,即“DOM树抽象成一个js对象”这个部分。于是,我希望通过本文,详细阐述我是怎么创建自己的这个抽象js对象。

Virtual Node的结构

Virtual DOM从某种意义上讲,是一个tree,tree的节点就是我所指的Virtual Node。那么一个Virtual Node作为一个js对象,应该拥有哪些属性呢?

{
  name: 'div', // 标签名称
  id: 'header', // 标签id,默认undefined
  class: ['float-right', 'font-big'], // 类数组,默认[]
  attrs: { // 从html字符串中解析出来的所有标签属性字符串
    id: 'header',
    class: 'float-right font-big',
    ...
  },
  parent: ..., // 父节点的引用,如果没有父节点就是null
  children: [...], // 子节点引用列表,如果没有子节点就是[]
  text: 'My BLOG', // 文本节点
  events: { // 事件绑定
    click(e) { ... },
  },
}

我本来想用tagName作为标签名属性,但是为了简洁,直接用name。parent只会有一个,而children会有多个。events只会在通过VNode还原为DOM Node的时候使用到,从HTML字符串解析到VNode的时候,是不会有的。

HTMLStringParser的实现

正如文章开头的需求,我希望解析抓取到的HTML string,快速找到自己想要的节点数据。有人说,使用jquery不就好了吗?也有人说,就算在node中我们也有cheerio啊。之所以我想自己实现,是为了:1.用最少的代码满足快速获取的需求,2.使用我自己定义的VNode结构。

我们希望这样来使用这个工具:

let parser = new HTMLStringParser(htmlstring)
let vnode = parser.getElementById('my-test') // 得到一个VNode
let text = vnode.text
let vnodes = vnode.getElementsByClassName('my-class') // 得到一组VNode

这种使用非常爽。比如说,你通过fetch得到了一个页面的html源码,想找到这个页面中的固定位置的title和link,那就非常容易(搞采集的小伙伴请当做什么都没看到)。

有了需求之后,我们就开始撸代码。

htmlparser2

大神Felix开发了htmlparser2,看这名字,显然还有一个htmlparser,htmlparser2是它的plus版。但是包括cheerio在内的很多第三方解析器都是采用了htmlparser2,因为它确实好用。

用htmlpareser2编程的思想,是注重“过程”。不像我们预期的,输入字符串得到结果,它更重视的是,把字符串输入之后,解析器去爬,爬的过程中会对字符串进行解释,html标签有非常明显的特点,就是有闭合标签,因此,htmlparser2的重要特征就是,有onopentag和onclosetag这两个事件。

但是,对于htmlparser2来说,它并不关心html标签的父子关系,它只关心标签的开合,因此,对于我而言,要做的,是在htmlparser2的过程中,去记录标签的父子关系,并最终构建自己的VNode。下面就是我的实现代码:

let elements = []let recordtree = []let parser = new Parser({
  onopentag(name, attrs) {
    let parent = recordtree.length ? recordtree[recordtree.length - 1] : undefined
    let vnode = {
      name: name,
      id: attrs.id,
      class: attrs.class ? attrs.class.split(' ') : [],
      attrs: attrs,
      parent,
      children: [],
      text: undefined,
    }
    if (parent) {
      parent.children.push(vnode)
    }
    recordtree.push(vnode)
    elements.push(vnode)
  },
  ontext(text) {
    let vnode = recordtree[recordtree.length - 1]
    if (vnode) {
      vnode.text = text.trim()
    }
  },
  onclosetag(name) {
    recordtree.pop()
  }
})
parser.parseChunk(htmlstring)
parser.done()

借助了两个变量,一个是elements,存储了所有的VNode,没有父子关系,按标签打开顺序,依次记录。另一个是recordtree,用来作为保存节点层级关系的临时变量,它的最后一个元素,其实就是当前正在处理的标签对应的vnode,而前一个标签,就是它的父级标签对应的vnode。

如此简单的一小段代码,就让我们拥有了所有html string的所有节点的VNode。我们可以通过elements变量获取任意一个。
因为javascript的object是引用型数据,因此处理parent和children简直不能再方便了。

节点选择器方法

DOM获取节点的方法主要是getElement(a)By系列,得到一个节点,最坏的打算是要遍历一颗树,这实在太昂贵了。但是,我们现在有了elements这个产量,它是一个包含了所有节点信息的数组,一个html标签节点就是一个元素,要找到一个元素实在是太容易了,只要使用js原生的数组操作方法就可以了。比如我们要找到所有包含mytest样式类的元素,只需要

elements.filter(item => item.class.contains('mytest'))

多么简单的操作。当然,我们还可以对算法进行优化,我们查找一个元素,无非按id或标签名或class或attribute查找,我们完全可以事先按照这四个进行分类,引用型数据又可以帮大忙,按其中一类查找时,就只遍历一个子集。

基于这样的设计,想怎么挑选就怎么挑选,可以挑选出同时具备myclass1和myclass2的元素。但是为了保持和DOM操作的相似性,我实现了如下方法:

function getElementById(id) {
  return elements.filter(item => item.id === id)[0]}function getElementsByClassName(className) {
  return elements.filter(item => item.class.indexOf(className) > -1)}function getElementsByTagName(tagName) {
  return elements.filter(item => item.name === tagName)}function querySelectorAll(selector) {
  let type = selector.substring(0, 1)
  let formula = selector.substring(1)
  switch (type) {
    case '#':
      return elements.filter(item => item.id === formula)
      break
    case '.':
      return getElementsByClassName(formula)
      break
    default:
      return getElementsByTagName(selector)
  }
}
function querySelector(selector) {
  return querySelectorAll(selector)[0]
}

另外,我还是实现一个简单的通过属性来获取元素的方法:

function getElementsByAttribute(attrName, attrValue) {
  return elements.filter(item => item.attrs[attrName] && item.attrs[attrName] === attrValue)
}

因为把所有元素扁平的存在elements里,这些方法的实现都变得超级简单。

VNode原型继承

对于一个VNode而言,除了上述我们给出的那些属性,我们也希望这个VNode拥有上面的这些获取方法,我们可以这样用:

let vnode = parser.getElementById('my-test')
let codes = vnode.getElementsByTagName('code')

也就是说,可以通过被选中的VNode来获取它的子元素里面的对应的元素。这个实现起来并不容易,因为你需要对所有的VNode进行方法设置,而且明显,这些方法和parser本身的方法是一致的,不应该重写。所以,我想到了使用原型链,这一js中最突出的特质。
首先,我们创建一个原型:

let VNodePrototype = {
  parent: null,
  children: [],
  getElementById(id) {
    getElementById.call(this, id)
  },
  // ...
}

这里之所以要用.call(this..是因为我们需要在一个单独的VNode中重新去考虑使用新的elements,因为当你把getElementById作用在一个VNode的时候,你是希望从它内部的元素中去获取,而不是从顶层的elements中获取。我们后文会有完整的源码链接,你应该阅读完整的源码,找到这个位置进行阅读。

那么如何把它的子元素都拿到呢?要知道虽然它有个children属性,但是这些元素仅仅是它的垂直一层的子元素,它还有孙元素,以及更低层的元素,索性,我们有递归,我们写一个递归来获取一个VNode所包含的所有节点:

function getVNodeElements(vnode) {
  let results = []
  vnode.children.forEach(item => {
    results.push(item)
    if (item.children.length) {
      results = results.concat(getVNodeElements(item))
    }
  })
  return results
}

这样就可以获取包含在这个VNode内的所有元素了。

有了原型之后,我们就可以通过原型继承的方式,创建我们的VNode,使我们的每一个VNode都具备上面这些基础方法:

function createVNode(name, attrs) {
  let obj = Object.create(VNodePrototype)
  obj.name = name
  obj.attrs = attrs
  obj.id = attrs.id
  obj.class = attrs.class ? attrs.class.split(' ') : []
  return obj
}

所以,当我们在构建一个VNode的时候,其实只需要按照我们设想的结构,把对应的属性加上去即可。

封装为Class

ES6的Class非常方便的让我们可以extends,因此,是封装一个解析器的最佳选择。我们把上面提到的所有函数或方法都提炼到这个类中,把elements当做它的一个隐私的属性,在不同的方法中可以共享,而原型则作为static属性,这样可以更省内存。

你可以在我的GitHub上阅读源码,并且按照README进行使用。

HTMLStringParser的使用

因为封装为Class,所以使用起来也超级方便,你只需要按照我们前面的想法去使用即可。

import HTMLStringParser from './HTMLStringParser'
let html = '...'
let parser = new HTMLStringParser(html)
let rootNodes = parser.getRoots()
let header = parser.getElementById('header')
let logo = header.getElementById('logo')
console.log(JSON.stringify(rootNodes[0]))

所有的API都按照我们的设计实现了。

renderToHTMLString

既然我们定义了自己的VNode,那么,我们就可以写一个方法,将我们的Virtual DOM反转为html字符串。对于反转字符串而言,其实我们只需要一个VNode的name, attrs, children属性即可,其他属性都没有用。

function renderToHTMLString(json) {
  let html = ''
  // if it is an Array, it means there are several nodes on the top level
  if (Array.isArray(json)) {
    json.forEach(node => {
      html += renderToHTMLString(node)
    })
    return html
  }
  // if it is an Object
  html += createNode(json)
  return html
}
function createNode(node) {
  let name = node.name
  let html = `<${name}`
   let voidElements = ['br', 'hr', 'img', 'input', 'link', 'meta', 'area', 'base', 'col', 'command', 'embed', 'keygen', 'param', 'source', 'track', 'wbr']
   let attrs = node.attrs
   let keys = Object.keys(attrs)
   if (keys && keys.length) {
     keys.forEach(key => {
       let value = attrs[key]
       if (value === '' || value === true) {
       html += ` ${key}`
     }
     else {
       html += ` ${key}="${value}"`
     }
    })
  }
  if (voidElements.indexOf(name) > -1) {
    html += ' />'
    return html
  }
  html += '>'
  if (node.text) {
    html += node.text + `</${name}>`
    return html
  }
  if (node.children && node.children.length) {
    html += renderToHTMLString(node.children)
  }
  html += `</${name}>`
  return html
}

你可以看到,我们的参数是json,这也就是说,实际上,我们可以利用这个方法来实现xml的解析和转换。考虑到一些html标签是没有闭合标签的,所以实际上我们最好还是用它来做html的处理。

处理事件绑定

最后一件事是,我们在还原Virtual DOM为真实DOM的时候,如何处理事件绑定的问题?在文章第一部分VNode的结构中,我们给出了events属性,那么如何实现事件绑定呢?

实际上,与把Virtual DOM还原为HTML字符串而言,还原为DOM更加简单:

function createElement(node) {
  let name = node.name
  let el = document.createElement(name)
  let attrs = node.attrs
  let events = node.events
  let attrKeys = attrs ? Object.keys(attrs) : []
  if (attrKeys && attrKeys.length) {
    attrKeys.forEach(key => {
      let value = attrs[key]
      el.setAttribute(key, value)
    })
  }
  let eventKeys = events ? Object.keys(events) : []
  if (eventKeys && eventKeys.length) {
    eventKeys.forEach(key => {
      let callback = events[key]
      el.addEventListener(key, callback, false)
    })
  }
  if (node.text) {
    el.innerText = node.text
    return el
  }
  if (node.children && node.children.length) {
    node.children.forEach(child => {
      let childEl = createElement(child)
      el.appendChild(childEl)
    })
  }
  return el
}

之所以简单,是因为我们有appendChild方法,这个方法避免了我们想尽一切递归办法去构造字符串。看上面的红色字体部分,使用addEventListener绑定事件回调函数,简直易如反掌。

小结

这篇文章之所以还有一个副标题指出“Virtual DOM之前”,是因为我们并没有完整的去实现一个Virtual DOM机制,相反,我们是实现了从DOM到Virtual DOM的过程,虽然我们写了createElement方法,把Virtual DOM还原为真实的DOM,但是这明显是不够的。本文的核心,是在利用htmlparser2实现一个html到js对象的过程,希望你能从中获得一些自己想要的东西。

2017-09-13 6401

为价值买单,打赏一杯咖啡

本文价值64.01RMB
已有1条评论
  1. […] 写完《HTMLStringParser:自己撸一个Virtual DOM之前》之后,我第一时间整理代码,把文章转载到掘金上,满足作为宅逼程序猿的虚荣感。但写完HTMLStringPareser之后,我并不满足,既然都已经到了吧html转换为对应的js对象解构了,而且连createElement都写了,为何不更进一步,把整个Virtual DOM也给实现了呢?于是开始手撸。这一下,把自己给摔进坑里,在实现diff的时候,几乎陷入了绝境。最后,在无法实现的前提先,做了妥协,最后才终于撸完了整个Virtual DOM,代码在这里,你可以自己慢慢拍砖。 […]