您现在的位置是:首页 >其他 >vue2源码网站首页其他

vue2源码

qq_40745468 2024-06-20 11:57:31
简介vue2源码

初始化环境

npm init -y 初始化 npm 环境。

然后执行 npm install rollup rollup-plugin-babel @babel/core @babel/preset-env --save-dev

一般类库都是采用 rollup 打包,因为打包的体积会比 webpack 小很多,所以常用于打包 js 库。

rollup-plugin-babel 是为了在 rollup 中可以使用 babel 来编译高级语法。不了解 babel 的可以点此查看

用 babel 则需要安装核心模块,所以安装了 @babel/core

@babel/preset-env 是 babel 的一种预设插件,就是一个插件的集合,里面涵盖了大量的插件。

#脚本命令

package.json 中配置命令。

{
  "scripts": {
    "dev": "rollup -cw"
  }
}

c 代表使用配置文件,默认是 rollup.config.js 文件。

w 代表监控文件变化时重新打包。

#配置文件

执行 rollup 打包命令时默认会寻找 rollup.config.js 文件。

import babel from 'rollup-plugin-babel'

// rollup 默认可以导出一个对象,作为打包的配置对象
export default {
  input: './src/index.js', // 入口文件
  output: {
    file: './dist/vue.js', // 出口文件
    name: 'Vue', // 打包后会在全局 global 上添加一个 Vue 属性
    // format 设置打包格式,有 esm 模块(es6);commonjs 模块(node);iife 模块(自执行函数);umd 模块(兼容 commonjs 和 amd)
    format: 'umd',
    sourcemap: true // true 表示可以调试源代码
  },
  plugins: [ // 需要用到的插件如 babel,插件都是函数,直接执行
    babel({ // 执行 babel 函数时会加载配置文件 .babelrc
      exclude: 'node_modules/**' // 排除 node_modules 中的所有文件
    })
  ]
}

#babel 配置文件

在 rollup 配置文件中配置 babel 插件时,可以直接在执行 babel(options) 函数时传入 options 配置参数,也可以建立配置文件 .babelrc,这样在执行 babel 插件函数时会自动加载配置文件中的配置。

{
  "presets": [
    "@babel/preset-env"
  ]
}

#起步

创建 src/index.js 入口文件,在文件中输入 export const a = 100,保存后使用 npm run dev 命令执行 rollup 打包。

打包后会出现 dist 文件夹,里面有 vue.jsvue.map.js 文件。其中 vue.js 是 rollup 的出口文件,查看文件内容,可以看到 rollup 打包时使用 babel 将 es6 语法转化为了 es5 语法。而 vue.map.js 则是开启了 sourcemap 后生成的可供调试源码的文件。

在 dist 目录下新建 index.html 文件,引入 vue.js 后,在控制台中打印 console.log(Vue) 可以看到成功输出了全局的 Vue 实例。这是因为在 rollup 配置文件中设置了出口文件的 name 属性。

Vue 的本质

使用 Vue 时,都是通过 new 的方式实例化一个 Vue 实例,这说明 Vue 是一个构造函数。

在 js 中除了 function 关键字,还可以使用 class 关键字创建一个构造函数语法糖的类,但用 class 会导致所有的方法都耦合在一起,不利于扩展。

当然用 class 也可以用原型扩展方法,但一般不这么使用,所以 Vue 使用 function 关键字创建构造函数,并通过原型扩展自身。

#Vue 构造函数

知道 Vue 本身是一个构造函数后,我们来开始着手打造一个 Vue。在 src/index.js 中创建一个 Vue 并导出,注意 new Vue 时会传入一个配置对象,所以参数为 options 对象。

/**
 * Vue 构造函数
 * @param {object} options 用户选项
 */
function Vue (options) {
  // ...
}

export default Vue

#初始化

在通过 new Vue(options) 拿到 options 参数后,就可以开始对 Vue 做初始化了。

function Vue (options) {
  this._init(options)
}

Vue.prototype._init = function (options) { // 用于初始化操作
  // ...
}

export default Vue

通过在原型上添加一个 _init 方法,就可以在实例化 Vue 时初始化。同时考虑到扩展性,应该让每个功能都是独立的,也就是将原型上的方法分门别类,如初始化操作就写在 src/init.js 文件中。

  • src/index.js
  • src/init.js
function Vue (options) {
  this._init(options)
}

export default Vue

这种写法就能保证功能独立,构造函数文件不会太复杂。但这么做我们发现在 src/init.js 中就丢失了 Vue 这个构造函数。Vue 框架是通过方法把 Vueindex.js 传递到 init.js,这样做如果想初始化什么,就可以创建一个方法,通过方法传递 Vue,然后在方法中添加原型方法。

  • src/index.js
  • src/init.js
import { initMixin } from './init'

function Vue (options) {
  this._init(options)
}

initMixin(Vue) // 传递 Vue 的同时扩展了 _init 方法

export default Vue

至于为什么不直接在 init.js 文件中引入 index.js 导出的 Vue,我的想法是 Vue 想把主线逻辑集成在入口文件 index.js 中。在 index.js 中执行 initMixin(Vue) 方法,查看 initMixin 方法就能知道在方法内部为原型添加了 _init() 方法。

#缓存用户选项 options

Vue 有许多属性和 API,这都是在不同阶段中设置的,缓存起来以方便其它方法可用。

// src/init.js
export function initMixin (Vue) {
  Vue.prototype._init = function (options) {
    const vm = this
    vm.$options = options // 将用户的选项挂载到实例上
  }
}

像这样将用户选项挂载到 vm.$options 上,其它方法就可以通过它来获取用户选项。这里要注意的是 Vue 规定的以 $ 开头的变量是 Vue 自身的属性,如 $data$nextTick 等。如果 new Vue 时在 data 中设置了以 $ 开头的属性,那么是无法通过 vm.$property 获取到的。

new Vue({
  data: {
    $name: 'eagle', // 无法通过 vm.$name 获取到 $name 属性
    name: 'eagle'
  }
})

#初始化状态之数据

Vue 的状态属性如 propsdatacomputedwatch 等都需要初始化,这些初始化操作都会放在 initState 函数中,如下就是初始化数据的流程。

export function initMixin (Vue) {
  Vue.prototype._init = function (options) {
    const vm = this
    vm.$options = options

    initState(vm) // 初始化状态
  }
}

function initState (vm) {
  const opts = vm.$options
  if (opts.data) {
    initData(vm)
  }
}

function initData (vm) {
  let data = vm.$options.data // 这个 data 就是 new Vue 时传入的 data,可能是函数也可能是对象
  data = typeof data === 'function' ? data.call(vm) : data // 如果是函数则执行函数拿到返回值
}

插播知识点

vue2 中根实例 data 可以是对象也可以是函数,组件中 data 必须是函数。

vue3 则规定最好是函数。

初始化数据的流程就是这样,接着新建 src/state.js 文件,把初始化状态这个分支给提取到该文件中。

  • src/init.js
  • src/state.js
import { initState } from './state.js'

export function initMixin (Vue) {
  Vue.prototype._init = function (options) {
    const vm = this
    vm.$options = options

    initState(vm) // 初始化状态
  }
}
```## 属性劫持

初始化数据时会对数据进行劫持,也就是观测,在 vue2 中采用了 `Object.defineProperty` 这个 api。但 `Object.defineProperty` 只能劫持观测已经存在的属性,如果新增或删除属性并不会被劫持,为此 vue2 提供了如 `$set``$delete` 这些 api,这里只看是如何进行观测的。

Vue 框架是在 `observe()` 方法中对数据进行劫持观测的,而 `observe` 是 Vue 中的一个核心模块:响应式模块,它会对数据进行劫持观测,会为 `data` 中的属性增加 `getter``setter`。

在 `src` 目录下新建 `observe` 目录,入口文件为 `index.js`。

- src/state.js
- src/observe/index.js

```javascript
import { observe } from './observe/index'

export function initState (vm) {
  const opts = vm.$options
  if (opts.data) {
    initData(vm)
  }
}

function initData (vm) {
  let data = vm.$options.data
  data = typeof data === 'function' ? data.call(vm) : data

  observe(data)  // 劫持 data,为 data 中的属性添加 getter 和 setter
}

#属性代理

Vue 在 src/state.jsinitData 方法中,将 data 属性缓存到了 vm._data 中,这样就可以通过 vm._data.xxx 访问到 data.xxx 了。

但我们知道在 Vue 中可以直接通过 vm.xxx 拿到 data.xxx,这是因为 Vue 对 data 做了属性代理,当操作 vm.xxx 时,就会代理到 vm._data.xxx 上。

// src/state.js
/**
 * 将 vm[key] 代理到 vm[target][key] 上
 * @param {object} vm 上下文
 * @param {string} target 要代理的目标
 * @param {string} key 要代理的属性
 */
function proxy (vm, target, key) {
  Object.defineProperty(vm, key, {
    get () {
      return vm[target][key]  // 访问 vm.name 返回 vm._data.name
    },
    set (newValue) {
      vm[target][key] = newValue
    }
  })
}

function initData (vm) {
  let data = vm.$options.data
  data = typeof data === 'function' ? data.call(vm) : data

  vm._data = data  // 将 data 对象缓存到 _data 中

  observe(data)  // 数据劫持

  for (let key in data) {
    proxy(vm, '_data', key)  // 将 vm[key] 代理到 vm._data[key] 上
  }
}

#总结

defineReactive() 方法是为传入的 data 对象属性添加 gettersetter 的。

proxy() 方法是将 vm[key] 代理到 vm._data[key] 的。

劫持数组的缺点

经过上一章对对象进行属性劫持观测后,observe() 会为最终的 data 对象中的每一个属性添加 gettersetterproxy() 会把 vm[key] 代理到 vm._data[key] 上。

这两个方法都是通过循环来劫持和代理的,当一个数组有很多的元素,循环劫持每个索引下标和代理会导致性能堪忧。加上一般数组是不会通过索引下标来操作元素的,而是通过诸如 pushpop 等方法来操作数组,所以 Vue 没有对数组的索引做循环劫持观测操作,转而对会修改数组本身的 7 个数组操作方法做了劫持。

#数组内容的劫持

虽然不会对数组的索引下标进行劫持,但因为数组的内容有可能也是个对象,所以需要对数组的内容做劫持操作。

// src/observe/index.js
class Observer {
  constructor (data) {
    if (Array.isArray(data)) {  // 如果 data 是数组
      this.observeArray(data)  // 对 data 数组的每一项做劫持
    } else {
      this.walk(data)
    }
  }

  walk (data) {
    Object.keys(data).forEach(key => defineReactive(data, key, data[key]))
  }

  /**
   * 对 data 数组的每一项做劫持,如果子项是对象就会劫持
   * @param {array} data 要劫持子项的数组
   */
  observeArray (data) {
    data.forEach(item => observe(item))
  }
}

在加了 observeArray() 方法后,如果数组中的元素为对象,那么这个子元素对象也会通过 walk() 方法被劫持观测了。

此时就可以监听到如 vm.hobbies[0].name hobbies 数组中第一项对象的获取和修改了。子元素是对象的情况现在可以监听了,但如果通过 7 个可以改变数组本身的操作方法来操作呢?要想监听到这 7 个操作方法对数组的修改,就需要对数组的函数进行劫持。

#数组的函数劫持,重写数组方法

函数的劫持,就是想要在数组方法原有的实现上,能够知道何时调用了这 7 个方法。这就需要我们保留数组原有的方法,同时重写这 7 个方法。Vue 中利用了装饰器模式中 aop 切片编程的思想,重写一个功能,功能内部涵盖以前的功能。新建 src/observe/array.js 文件。

  • src/observe/index.js
  • src/observe/array.js
import { newArrayProto } from './array'

class Observer {
  constructor (data) {
    if (Array.isArray(data)) {
      // 将数组的原型指向我们重写后的新原型,以此保留数组原有方法,同时重写 7 个数组操作方法
      data.__proto__ = newArrayProto
      this.observeArray(data)  // 劫持数组各项元素
    }
  }
}

通过给数组添加多一层原型链,在数组原有方法实现上,重写了 7 个操作方法,这样就可以监听到什么时候调用了什么方法。

7 个方法中,pushunshiftsplice 三个方法都能够向数组中添加数据,如果添加的是一个对象,也需要对其进行劫持。

#新增数据的劫持

// src/observe/array.js
methods.forEach(method => {
  newArrayProto[method] = function (...args) {
    const result = oldArrayProto[method].call(this, ...args)
    
    let inserted  // 新增的数据数组
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args  // push 和 unshift 方法参数都是新增的内容
        break
      case 'splice':
        // splice 方法第一个参数是起始索引,第二个参数是删除个数,第三个参数开始就是新增的数据
        inserted = args.slice(2)
        break
    }
    if (inserted) {
      // 在这里对新增的数据进行观测
    }
    
    return result
  }
})

如上只要 inserted 有值,就证明数组添加了内容,需要对新增的内容进行劫持观测。

但问题就是如何进行观测?观测数组子元素的方法 observeArray()src/observe/index.js 文件的 Observer 类中,可能你会想直接在 array.js 文件引入 Observer 类,实例化后调用 observeArray() 方法即可。

还记得在上一章中,我们提到劫持观测一个对象前,需要判断该对象是否已经被劫持过。

Vue 就是用接下来的方法巧妙又恶心的解决了观测数组新内容和判断对象是否劫持过的两个问题。

  • src/observe/index.js
  • src/observe/array.js
class Observer {
  constructor (data) {
    // this 就是 Observer 类的实例,放在 data 的自定义属性上
    // 这样在 array.js 中就可以调用到 Observer 实例的方法
    // 同时下方的 observe 方法也可以通过判断数据是否有 __ob__ 标识来判断对象是否劫持观测过
    data.__ob__ = this

    if (Array.isArray(data)) {}
    else {}
  }
}

function observe (data) {
  if (typeof data !== 'object' || data == null) {
    return
  }

  if (data.__ob__ instanceof Observer) {  // 如果数据对象上的 __ob__ 标识是 Observer 类的实例,说明已经观测过了
    return data.__ob__  // 返回观测过了的 Observer 实例即可
  }

  return new Observer(data)
}

通过给 data.__ob__ 赋值 this 这个 Observer 类实例,就可以在 array.js 中调用 Observer 类的 observeArray 方法了,同时也解决了判断对象是否被观测过的问题。

但这样会导致一个 bug,就是当 data 为对象时,给 data.__ob__ 赋值一个实例对象后,对 data.__ob__ 这个对象进行劫持观测的时候又添加一个 __ob__ 对象,对新添加的 __ob__ 对象进行观测还是会添加 __ob__ 对象,如此反复最终导致死循环。

#解决劫持对象时 ob 死循环问题

解决这个问题其实很简单,就是让 data.__ob__ 这个属性无法遍历。因为劫持对象时就是遍历对象的属性来观测的,当 __ob__ 无法遍历出来时,自然就不会进入无限递归。

// src/observe/index.js
class Observer {
  constructor (data) {
    Object.defineProperty(data, '__ob__', {
      value: this,
      enumerable: false  // 将 __ob__ 变为不可枚举
    })

    if (Array.isArray(data)) {}
    else {}
  }
}
``经过前面的章节后,我们成功的劫持观测了传入 `new Vue` 中的 `data`,接下来就可以开始和视图挂钩了。

## [#](https://blog.xqtcat.cn/vue/source-code/v2_write/4_observe_array.html#%E6%95%B0%E6%8D%AE%E6%9B%BF%E6%8D%A2%E6%96%B9%E6%A1%88)数据替换方案

```html
<div id="app">
  <div>{{ name }}</div>
  <div>{{ age }}</div>
</div>

<script src="vue.js"></script>
<script>
  const vm = new Vue({
    data () {
      return {
        name: 'eagle',
        age: 23
      }
    },
    el: '#app'
  })
</script>

使用 Vue 的同学都知道,指定 el 属性后,Vue 会把数据解析到 el 元素中,将模板中的 nameage 进行替换。问题是要怎么替换呢?替换方案有以下几种:

  1. 模板引擎:性能很差,每次更新数据后,都会拿模板用正则匹配替换数据。Vue1.0 的时候,没有引入虚拟 DOM 的概念,就是用模板引擎,所以性能很差。
  2. 采用虚拟 DOM:数据变化后比较虚拟 DOM 的差异,最后更新需要更新的地方。Vue2.0 开始就引入了虚拟 DOM 的概念。

采用虚拟 DOM 方案的核心,就是需要将模板从 html 的树形结构,变成用 js 语法描述 ast 语法树,通过 js 语法去生成虚拟 DOM vnode

在 Vue 中,不仅可以通过 el 属性指定模板,还可以用 template 属性直接书写模板,但无论是用哪种属性,最终 Vue 都会把模板转化成 render() 函数,通过调用 render() 函数生成虚拟 DOM vnode

当然,用户也可以在 new Vue 时传入 render 函数,这样就不会将 templateel 转化为 render 函数,三者的权重顺序是 render() > template > el

#获取到要编译的模板 template

// src/init.js
export function initMixin (Vue) {
  Vue.prototype._init = function (options) {
    const vm = this
    vm.$options = options

    initState(vm)

    if (options.el) {
      vm.$mount(options.el)  // $mount 实现数据的挂载,渲染数据
    }
  }

  /**
   * 实现数据的挂载,将数据渲染到指定的元素上
   * @param {string} el 指定的元素,一般为元素 id
   */
  Vue.prototype.$mount = function (el) {
    const vm = this
    el = document.querySelector(el)  // 获取到元素
    let ops = vm.$options
    if (!ops.render) {  // 如果用户没有自己写 render 函数
      let template
      if (!ops.template && el) {  // 没有用 template,但是指定了 el 且 el 元素存在
        template = el.outerHTML  // 这里不考虑兼容性细节,直接用 outerHTML 获取整个元素字符串
      } else {
        if (el) {
          template = ops.template  // 用户传了 template
        }
      }

      if (template) {  // 这里就拿到了用户传入的 template 或是 el 转化的 template 字符串
        // 在这里对模板进行编译
      }
    }
  }
}

注意

以上代码并没有处理 el 和 template 同时不存在的情况,同时 el 暂时也是必传属性,也没有注意 outerHTML 的兼容性,这是需要注意的。

虽然没有注意细节,但是经过这一步,就能拿到最终要编译生成 render() 函数的模板 template 字符串了。

#将 template 转化为 render 函数

将 template 编译为 render 函数是 Vue 中的编译模块做的事,新建 src/compiler/index.js 文件。

  • src/init.js
  • src/compiler/index.js
import { compilerToFunction } from './compiler/index.js'

export function initMixin (Vue) {
  Vue.prototype.$mount = function (el) {
    // ...
    if (!ops.render) {
      // ...

      if (template) {  // 这里就拿到了用户传入的 template 或是 el 转化的 template 字符串
        // 在这里对模板进行编译
        const render = compilerToFunction(template)  // 对模板进行编译
        ops.render = render
      }
    }
  }
}

compilerToFunction 方法要做的有两件事:

  1. template 编译成 ast 语法树。
  2. 生成 render 方法,render() 返回的就是虚拟 DOM。

具体的细节下一章就会介绍,这里先解决一个小问题,就是 VsCode 中自动引入时不会带上 index.js,比如刚刚的 import { compilerToFunction } from './compiler/index.js',VsCode 自动引入的结果是 import { compilerToFunction } from './compiler',rollup 并不认识,会报错,要想解决这个小问题可以安装一个插件。

#安装插件解决引入问题

执行 npm install @rollup/plugin-node-resolve 安装插件,然后在 rollup.config.js 中执行插件函数即可。

import resolve from '@rollup/plugin-node-resolve'

export default {
  plugins: [
    resolve()
  ]
}

正则表达式

一段 template 模板字符串由标签、标签属性和标签文本值组成,要想将其解析成 ast 语法树,就需要先将标签、属性和文本解析出来,并根据父子关系组合成一棵树,为此需要借助正则表达式。

可以借助正则可视化网站open in new window来查看效果,也可以去看视频讲解,否则可能比较难以理解。

// src/compiler/index.js
const ncname = `[a-zA-Z][\-\.0-9_a-zA-Z]*`
const qnameCapture = `((?:${ncname}\:)?${ncname})`
const startTagOpen =  new RegExp(`^<${qnameCapture}`)  // 匹配到的分组是一个开始标签名,如 <div 或带命名空间的 <div:xxx,注意不带 > 符号
const endTag = new RegExp(`^<\/${qnameCapture}[^>]*>`)  // 匹配的是结束标签,如 </div>
// 匹配属性,第一个分组是属性的 key,第二个分组是 = 号,第三、四、五分组是属性的 value 值
const attribute = /^s*([^s"'<>/=]+)(?:s*(=)s*(?:"([^"]*)"+|'([^']*)'+|([^s"'=<>`]+)))?/
const startTagClose = /^s*(/?)>/  // 匹配开始标签的 > 符号或自闭和标签,如 <div> 中的 > 或 <br />
const defaultTagRE = /{{((?:.|
?
)+?)}}/g  // 匹配 {{}} 表达式,如 {{ name }}

以上便是 vue2 中编译 template 模板字符串时所用的正则表达式。

冷知识

vue3 中不是采用正则的方式来编译的,而是一个字符一个字符的判断。

#match 方法返回的结果

使用 String.prototype.match() 方法匹配正则表达式时,返回的结果是一个匹配后的数组,举几个例子:

  1. hello
     这段字符串,匹配 startTagOpen 开始标签正则时,返回的结果为 ['

#解析标签、属性、文本

#匹配开始标签及其属性

// src/compiler/index.js
/**
 * 解析传入的 html 字符串,生成 ast 语法树
 * @param {string} html 要解析的 html 字符串
 */
function parseHTML (html) {
  // 截取 html 字符串
  function advance (length) {
    html = html.substring(length)
  }
  
  // 解析开始标签及其属性
  function parseStartTag () {
    const start = html.match(startTagOpen)  // 尝试匹配开始标签
    if (start) {  // 如果为开始标签
      const match = {
        tagName: start[1],  // 标签名
        attrs: []  // 标签属性
      }
      advance(start[0].length)  // 匹配到后从字符串中删除开始标签

      let attr, end
      while (
        !(end = html.match(startTagClose))  // 如果开始标签没有结束,即还有属性存在
        &&
        (attr = html.match(attribute))  // 匹配开始标签的属性
      ) {
        advance(attr[0].length)  // 从字符串中删除匹配到的属性
        match.attrs.push({
          name: attr[1],  // 分组第一个为属性的 key
          value: attr[3] || attr[4] || attr[5] || true  // 分组第三、四、五为属性 value 值;如果属性是 disabled 这样只有 key,那么值设为 true
        })
      }

      if (end) {  // 匹配到开始标签的结束符号 > 了
        advance(end[0].length)  // 从字符串中删除标签结束符
      }

      return match
    }

    return false  // 不是开始标签
  }

  while (html) {  // 每次匹配到后都从模板字符串中删除,直至字符串为空
    /**
     * 如果为 0,说明是一个开始标签或结束标签,如 '<div>hello</div>' 或 '</div>'
     * 如果大于 0,说明是一段文本的结束位置,如 'hello</div>'
     */
    let textEnd = html.indexOf('<')

    if (textEnd === 0) {  // html 字符串开头是一个标签
      const startTagMatch = parseStartTag()  // 如果为开始标签,则解析标签和属性并返回封装的 match 对象

      if (startTagMatch) {  // 如果本轮解析出的是开始标签,则跳过后面的操作
        continue
      }
    }
  }
}

export function compilerToFunction (template) {
  // 将 template 转化成 ast 语法树
  let ast = parseHTML(template)

  // 生成 render 方法
}

compilerToFunction 方法中,首先要将 template 模板字符串转化为 ast 语法树,转化过程要先解析模板字符串中的各个标签、属性以及文本。

parseHTML 方法中,advance 方法负责截取模板字符串;parseStartTag 方法负责解析开始标签及其属性,解析后返回封装的 match 对象,如果模板字符串不是开始标签及其属性开头,则返回 false

parseHTML 方法会对模板字符串进行循环,每次循环寻找 < 字符下标,如果下标为 0 说明是标签,如果下标大于 0 说明是文本的结束位置。当开头为标签时,会调用 parseStartTag 方法解析标签,看看是否为开始标签。

#匹配文本

/**
 * 解析传入的 html 字符串,生成 ast 语法树
 * @param {string} html 要解析的 html 字符串
 */
function parseHTML (html) {
  // 截取 html 字符串
  function advance (length) { /* ... */ }
  
  // 解析开始标签及其属性
  function parseStartTag () { /* ... */ }

  while (html) {
    let textEnd = html.indexOf('<')

    if (textEnd === 0) { /* ... */ }

    if (textEnd > 0) {  // textEnd 大于 0 是文本结束位置
      let text = html.substring(0, textEnd)  // 文本内容
      if (text) {
        advance(text.length)  // 从模板字符串中删除这段文本
      }
    }
  }
}

#匹配结束标签

/**
 * 解析传入的 html 字符串,生成 ast 语法树
 * @param {string} html 要解析的 html 字符串
 */
function parseHTML (html) {
  // 截取 html 字符串
  function advance (length) { /* ... */ }
  
  // 解析开始标签及其属性
  function parseStartTag () { /* ... */ }

  while (html) {
    let textEnd = html.indexOf('<')

    if (textEnd === 0) {
      const startTagMatch = parseStartTag()
      if (startTagMatch) {
        continue
      }

      const endTagMatch = html.match(endTag)  // 尝试匹配结束标签
      if (endTagMatch) {  // 本次循环匹配到的是结束标签
        advance(endTagMatch[0].length)  // 从模板字符串中删除结束标签
        continue
      }
    }
  }
}

自此,parseHTML 方法趋于完善了。首先解析开始标签及其属性,然后解析文本内容,最后解析结束标签。

#拿到解析出来的标签、属性、文本

function parseHTML (html) {
  function start (tag, attrs) {
    // 解析到开始标签及其属性
  }
  function chars (text) {
    // 解析到文本内容
  }
  function end () {
    // 解析到结束标签
  }

  /* ... */

  while (html) {
    let textEnd = html.indexOf('<')

    if (textEnd === 0) {  // 模板字符串开头是标签
      const startTagMatch = parseStartTag()
      if (startTagMatch) {  // 匹配开始标签及其属性
        start(startTagMatch.tagName, startTagMatch.attrs)  // 解析到开始标签及其属性
        continue
      }

      let endTagMatch = html.match(endTag)
      if (endTagMatch) {  // 本次循环匹配到的是结束标签
        end()  // 解析到结束标签
        advance(endTagMatch[0].length)
        continue
      }
    }

    if (textEnd > 0) {  // 模板字符串开头是文本
      let text = html.substring(0, textEnd)  // 文本内容
      if (text) {
        chars(text)  // 解析到文本内容
        advance(text.length)  // 从模板字符串中删除这段文本
      }
    }
  }
}

#转化为抽象语法树

在解析标签、属性、文本的过程中,就需要逐步的将它们转化为一颗抽象的树,一颗 ast 语法树,数据结构大致如下:

{
  tag: 'div',  // 标签名
  type: 1,  // 标签元素类型,同 nodeType
  attrs: [],  // 标签属性
  parent: null,  // 父节点,root 根元素为 null
  children: []  // 子节点列表
}

转化的关键点就是要知道每个节点间的父子关系,需要知道谁是谁的父亲,谁是谁的儿子。我们可以维护一个栈,当解析出第一一个开始标签时,就入栈作为根元素。之后每当解析出开始标签时,当前栈中最后一个元素即是其父元素,构建父子关系并入栈。当解析到文本内容时,与当前父节点也就是栈中最后一个节点构建父子关系,但无需入栈。当解析到结束标签时,就将对应的开始标签出栈。最后,当栈空的时候,一颗抽象的语法树也就构建完成了,可以结合下图理解。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xmFDavB6-1684851097137)(https://blog.xqtcat.cn/assets/%E5%88%A9%E7%94%A8%E6%A0%88%E6%9E%84%E5%BB%BA%E7%88%B6%E5%AD%90%E5%85%B3%E7%B3%BB.22fb0e83.png)]

function parseHTML (html) {
  const ELEMENT_TYPE = 1  // 元素类型
  const TEXT_TYPE = 3  // 文本类型
  const stack = []  // 用于存放元素的栈,用栈来构造一棵树
  let currentParent  // 指针,指向栈中最后一个,也就是下一个开始标签或文本的父亲
  let root  // ast 树的根节点

  // 用于创建 ast 语法树的一个节点
  function createASTElement (tag, attrs) {
    return {
      tag,
      type: ELEMENT_TYPE,
      children: [],
      attrs,
      parent: null
    }
  }

  function start (tag, attrs) {
    let node = createASTElement(tag, attrs)  // 根据开始标签及其属性创建 ast 语法树节点
    if (!root) {
      root = node  // 根节点为空则设置根节点
    }
    if (currentParent) {
      node.parent = currentParent  // 指定当前节点的父节点
      currentParent.children.push(node)  // 添加当前父亲的子节点
    }
    stack.push(node)  // 节点入栈
    currentParent = node  // 当前父亲 currentParent 为栈中的最后一个节点
  }

  function chars (text) {
    text = text.replace(/s/g, '')  // 将文本中的空白字符替换为空,替换后文本仍然有值才放入当前父亲的子数组中
    text && currentParent.children.push({  // 文本是栈中最后一个节点的子节点,直接放入 currentParent.children 中
      type: TEXT_TYPE,
      text,
      parent: currentParent
    })
  }

  function end () {
    stack.pop()  // 解析到结束标签后,就把栈中的节点弹出
    currentParent = stack[stack.length - 1]  // 重新设置 currentParent 为栈中最后一个节点
  }
}

经过以上操作,我们就获得了一颗以 root 为根节点的树,用 js 语法描述的一颗抽象的树。

以上便是将 template 模板字符串转化为 ast 语法树的过程,在解析出标签、属性及文本的过程中,用栈逐步构造出一颗树。

#没有处理单标签和注释

在经过以上操作后,我们成功的将模板字符串转化为了一颗 ast 语法树。但是你会发现,如果模板字符串中出现了单标签的话,转化的结果就会有偏差;如果出现了注释的话,转化的过程甚至会陷入死循环。下面来探究一下根本原因。

首先是单标签情况,在前面的章节中,我们通过 outerHTML 属性拿到了模板字符串,但这里要注意一点就是单标签就算在 html 中书写了闭合符号如 <br />,在解析成 html 后实际是 <br>,所以通过 outerHTML 拿到的模板字符串中,单标签实际上是没有闭合的,这样会导致单标签解析不到结束符号,从而无法让单标签出栈。所以单标签一旦入栈,就会一直作为后续节点的父节点。

然后是注释情况,html 中的注释是 <!-- --> 这样的,以 < 符号开头,所以解析过程中会认为是一个标签,从而开始匹配开始标签和结束标签。但是这两个标签正则都匹配不上,所以不会执行 advance 方法删除,就这样又进入了下一次循环,从而导致死循环。

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。