Vue 源码 | 300行代码从模板编译到响应式原理

1,157 阅读14分钟

导读

一个功能丰富、可用性高的框架,其源码一定包含了很多边界情况的处理,其核心逻辑占比反而很少。初学者如果直接读源码,很容易迷失在繁琐的特殊情况处理中,很难真正掌握框架的核心功能实现原理。

这里我将 vue 最核心的功能拆分出来,一步步拆解其实现方式,实现一个从模板编译到数据响应式的简易框架,探讨 vue 的核心原理 ~

点击进入 🔗 jsbin.com/niyapom , 先体验一下简易 vue 的功能。

简易框架与 vue 保持一致的语法。下述代码引入 myVue 脚本,创建 vue 实例,支持template,data,methods 等选项,主要功能有模板编译、数据响应式、事件绑定等。

const vm = new myVue({
  data() {
    return {
      num1: 1,
      num2: 2,
    }
  },
  template: `<div class="container" style="margin: 10px;">
    <div class="btns">
      <span>num1: {{num1}}</span>
      <button @click="addNum1">add</button>
      <button @click="reduceNum1">reduce</button>
    </div>
    <div class="btns">
      <span>num2: {{num2}}</span>
      <button @click="addNum2">add</button>
      <button @click="reduceNum2">reduce</button>
    </div>
    <div>num1 x num2 = {{num1*num2}}</div>
    <div>num1 + num2 = {{num1+num2}}</div>
  </div>`,
  el: '#app',
  methods: {
    addNum1() {
      this.num1 += 1
    },
    reduceNum1() {
      this.num1 -= 1
    },
    addNum2() {
      this.num2 += 1
    },
    reduceNum2() {
      this.num2 -= 1
    }
  }
})

流程概述

了解了实现的功能范围后,我们大致看一下基本思路。其工作流程概述如下:

1、通过 template 选项获取模板字符串(形如: <ul><li>列表1</li><li>列表2</li></ul> ,即一个 String 形式的 HTML)

2、将模板字符串解析为 AST 树,建立虚拟 DOM

  • 虚拟 DOM 的结构简化了,只保留 tagName 、children 和 attributes 属性,tagName 表示 DOM 的标签名,children 为子节点列表, attributes 表示 html 上属性。
  • vue 源码流程为:html字符串 -> ast 树 -> render 方法 -> vnode(虚拟dom) -> 真实 dom。而这里只保留三个环节:html字符串 -> vnode -> 真实 dom。

3、根据虚拟 DOM 创建真实 DOM 树,挂载到指定的 HTML 节点上。

  • 通过 document.createElement 、document.createTextNode 等 API 创建真实的 DOM 树
  • 创建过程中,将 class、style 等属性添加到 DOM 节点中,并注册 @ 关键字对应的事件

4、将 data 渲染到模板中

  • 识别 HTML 元素中的 mustache 语法 (即模板中的 {{xxx}})
  • 将 mustache 语法替换为对应 data 的值:支持 mustache 中对象属性获取、表达式执行等;获取 data 中的值时,添加对应 key 的依赖

5、实现 data 响应式更新

  • 在数据更新时,通知所有依赖方,更新对应的虚拟 DOM 节点的内容


下面重点解析五个步骤的实现方式~

Step 1:获取模板字符串

首先获取 模板字符串。模板字符串这里提供两种形式,一种是 type="x-template" 的 script 标签,一种是直接输入 字符串模式。这个环节比较简单,就是得到一个字符串形式的模板

<!-- 方式一:script 标签模式 -->
<!-- 在 html 中: -->
<script type="text/x-template" id="template">
  <div class="container" style="margin: 10px;">
    <div class="btns">
      <span>num1: {{num1}}</span>
      <button @click="addNum1">add</button>
      <button @click="reduceNum1">reduce</button>
    </div>
    <div class="btns">
      <span>num2: {{num2}}</span>
      <button @click="addNum2">add</button>
      <button @click="reduceNum2">reduce</button>
    </div>
    <div>num1 x num2 = {{num1*num2}}</div>
    <div>num1 + num2 = {{num1+num2}}</div>
  </div>
</script>
<script>
    <!-- 获取 script 元素,获取其中文本内容 -->
    const tmpl = document.querySelector(options.template).innerHTML
</script>
<!-- 方式二:纯字符串形式 -->
<script>
<!-- template 就是一个字符串,直接获取 -->
const tmpl = options.template
</script>

如下图所示,通过这一步,我们得到一个模板字符串。注意,这并不是 HTML,只是一些换了行的字符串。

Step 2:从模板字符串 到 虚拟 DOM

分析一波~

先从输入和输出的角度分析。先举一个简单的例子:

// 输入为
const tmpl = `<ul class="list"><li>列表1</li><li>列表2</li></ul>`
// 希望得到的输出为
const vnode = {
    "tagName": "ul",
    "children": [
        {
            "tagName": "li",
            "children": [
                "列表1"
            ],
            "attributes": {}
        },
        {
            "tagName": "li",
            "children": [
                "列表2"
            ],
            "attributes": {}
        }
    ],
    "attributes": {
        "class": "\"list\""
    }
}

看起来从输入到输出还有一定的距离,那就先从简单的入手。

2.1 拆解模板结构

模板有三种结构:开始标签、结束标签、文本内容。

第一步要做的,就是将这三种结构拆分出来。

变量介绍:这里使用 word 这个全局变量,记录当前遍历到的不完整的结构。使用 stack 变量来记录所有目前得到的标签。

思路介绍:

  • 在一个格式正确的模板中,当遇到一个 '<' 符号,那意味着一个开始标签或结束标签出现了,换句话说,文本内容结束了,这时应该将已有内容压入栈中,word 清空并初始化为最新的字符 '<'。[9-16行]
  • 遇到一个 '>' 符号,说明一个标签结束了,那么将 word += '>' 后,推入栈中,并清空 word。[3-8行]
  • 此外其他符号,都正常叠加在 word 上即可。
for(let i = 0; i < tmpl.length; i += 1) {
  const char = tmpl[i]
  if (char === '>') {
    word += char
    word = word.trim()
    stack.push(word)
    word = ''
    // 那就结束一个标签啦
  } else if (char === '<') {
    // 忽略空内容
    if(word.trim()) {
      stack.push(word.trim())
    }
    // 把之前的内容压入栈中
    word = char
  } else {
    word += char
  }
}

做完这一步,就得到了开始标签、内容、结束标签的列表。

2.2 解决父子关系

下一步,就是思考:在遍历的过程中,是否能解决父子关系? 这里的解法是,每出现一个结束标签,只要往前找,找到名字相同的标签,那它们就可以构成一个节点。

*注意,这里只考虑有开始标签和闭合标签的情况,不考虑单标签(如 )。

思路如下:

  • 解析得到标签名 tagName。
  • tagName 如果以 '/' 开头,那是一个结束标签,否则是一个开始标签
    • 对于开始标签,正常 push 进入 stack 即可
    • 对于结束标签,只需要在 stack 中,挨个 pop 出来,比较名字是否相同。
      • 如果不同,说明是该 node 的「子节点」,只需要将其推入 children 属性中即可
      • 如果相同,说明是对应的「开始标签」,那么这一个 node 的构建就完成了。完成一个 node 后,需要推回栈中,因为它需要作为 children 被挂载在外层节点。
  const tagName = word.slice(1, -1).split(' ')[0]
  if(tagName[0] === '/') {
    // 如果是个结束标签
    const endTag = tagName.slice(1)
    const node = {
      tagName: endTag,
      children: [],
    }
    while(stack.length) {
      const tag = stack.pop()
      if(typeof tag === 'string' && tag.slice(1, -1).split(' ')[0].trim()  === endTag.trim()) {
        // 遇到开始标签,完成
        break
      } else {
        // 注意标签的顺序
        node.children.unshift(tag)
      }
    }
    stack.push(node)
  } else {
    stack.push(word)
  }

经过这一步之后,就得到了具有父子关系的 vnode 结构。如下:

2.3 解析开始标签中的属性

既然 attributes 存在于开始标签中,那就在遇到开始标签时进行解析操作,即上述代码 [12行] 之前插入解析函数,返回解析结果。

解析函数传入的参数为 node 节点和 开始标签原始文本 rawTag。

例如:根据 rawTag 内容进行属性解析,结果存放在 node.attributes 中。以 rawTag = '<button @click="reduceNum2">' 为例,得到的结果应为 attributes: {@click: '"addNum1"'}。

思路如下:

  • 由于 attributes 的值可能存在空格,比如 v-for="item in items",所以不能使用空格进行切割
  • 遍历整个字符串,记录双引号出现的状态。若出现空格,但引号处于未闭合状态,忽略这个空格;若引号闭合,则为一个 独立的属性。
function parseAttributes(node, rawTag) {
  const tag = rawTag.slice(1, -1)
  // 不能使用空格作为分割符,因为需要忽略双引号内的空格 quetos
  // 错误写法:const attributes = tag.slice(1, -1).split(' ').slice(1).filter((attr) => attr.trim())

  let inQuotes = false // 是否出现一个未闭合的引号
  let attr = ''
  const attributes = []
  for(const char of tag) {
    if(char === ' ') {
      // 出现空白
      if(inQuotes) {
        // 刚刚出现一个没匹配的引号,那不管这个空格
        attr += char
      } else {
        // 过滤掉空的 attr
        attr.trim() && attributes.push(attr.trim())
        attr = '' // 清空
      }
    } else {
      attr += char
      if(char === '"') { // 遇到 " 符号,切换 inQuotes 状态
        inQuotes = !inQuotes
      }
    }
  }
  // 存入最后一个结果
  attr.trim() && attributes.push(attr.trim())
  // 将 attributes 列表存入 node.attributes 属性中
  for(const attr of attributes) {
    if(attr.indexOf('=') === -1) continue
    const key = attr.split('=')[0]
    const val = attr.split('=')[1]
    node.attributes[key] = val
  }
  return node
}

attributes 处理结果如下:

2.4 小结

总结下模板字符串解析为虚拟 DOM 的三个步骤:

  • 先拆分标签和文本内容。
  • 再细化标签,区分是开始标签还是结束标签。
    • 开始标签直接推入栈中
    • 结束标签就往前找,一直找到同名的开始标签,完成了节点的构建推回栈中
  • 处理开始标签中的属性,添加到虚拟 DOM 的 attributes 属性中

完整代码如下:

function genVnode(tmpl) {
  const stack = []
  let word = ''
  for(let i = 0; i < tmpl.length; i += 1) {
    const char = tmpl[i]
    if (char === '>') {
      word += char
      word = word.trim()
      if(word) {
        // 如果 trim 后为空白,直接跳过
        const tagName = word.slice(1, -1).split(' ')[0] // 还需要考虑有attr的情况
        if(tagName[0] === '/') {
          // 如果是个结束标签
          const endTag = tagName.slice(1)
          let node = {
            tagName: endTag,
            children: [],
            attributes: {}
          }
          while(stack.length) {
            const tag = stack.pop()
            if(typeof tag === 'string' && tag.slice(1, -1).split(' ')[0].trim()  === endTag.trim()) {
              // 找到了对应的开始标签,解析 attributes
              node = parseAttributes(node, tag)
              // 完成
              break
            } else {
              // 注意标签的顺序
              node.children.unshift(tag)
            }
          }

          if(getType(node) === 'Array') {
            stack.push(...node)
          } else {
            stack.push(node)
          }
          
        } else {
          stack.push(word)
        }
      }
      word = ''
      // 那就结束一个标签啦
    } else if (char === '<') {
      if(word.trim()) {
        stack.push(word.trim())
      }
      // 把之前的内容压入栈中
      word = char
    } else {
      word += char
    }
  }
  return stack[0] // 因为返回的是一个列表。根据 vue 模板只有一个根节点的要求,返回第一个节点即可
}

function parseAttributes(node, rawTag) {
  const tag = rawTag.slice(1, -1)
  // 不能使用空格作为分割符,因为需要忽略双引号内的空格 quetos
  // 错误写法:const attributes = tag.slice(1, -1).split(' ').slice(1).filter((attr) => attr.trim())

  let inQuotes = false // 是否出现一个未闭合的引号
  let attr = ''
  const attributes = []
  for(const char of tag) {
    if(char === ' ') {
      // 出现空白
      if(inQuotes) {
        // 刚刚出现一个没匹配的引号,那不管这个空格
        attr += char
      } else {
        // 过滤掉空的 attr
        attr.trim() && attributes.push(attr.trim())
        attr = '' // 清空
      }
    } else {
      attr += char
      if(char === '"') { // 遇到 " 符号,切换 inQuotes 状态
        inQuotes = !inQuotes
      }
    }
  }
  // 存入最后一个结果
  attr.trim() && attributes.push(attr.trim())
  // 将 attributes 列表存入 node.attributes 属性中
  for(const attr of attributes) {
    if(attr.indexOf('=') === -1) continue
    const key = attr.split('=')[0]
    const val = attr.split('=')[1]
    node.attributes[key] = val
  }
  return node
}

Step 3:从虚拟 DOM 到真实 DOM

虚拟 DOM 树,其实就是多叉树。熟悉二叉树的朋友,应该很容易想到递归。我们只要解决一个层级的问题,剩下的交给递归来做就好啦。

由于创建节点还比较复杂,使用函数包装一下~

  • 如果是标签节点,需要处理属性,这里处理了 class,style 和 @开头的事件监听,如下所示。
  • 如果是文本节点,需要关注是否存在 mustache 语法,并进行替换和追踪。这里先不展开,将在 Step4-5中介绍。
function createOneNode (vnode) {
  // 1、非文本节点
  const tag = vnode.tagName
  if(tag) {
    const node = document.createElement(tag)
    // 将 class 加上
    if(vnode.attributes && vnode.attributes.class) {
      node.classList.add(vnode.attributes.class.slice(1, -1))
    }
    // 将 style 加上
    if(vnode.attributes && vnode.attributes.style) {
      node.style.cssText += vnode.attributes.style.slice(1, -1)
    }

    // 将 事件监听 加上
    if(vnode.attributes) {
      for(const key in vnode.attributes) {
        const val = vnode.attributes[key];
        if(key[0] === '@') {
          const handlerName = val.slice(1, -1)
          node.addEventListener(key.slice(1), function() {
            methods[handlerName].call(data)
          })
        }
      }
    }
    return node
  }
  // 2、文本节点
  const node = document.createTextNode(vnode) 
  return node
}

还有比较准确的,获取对象类型的函数:

  function getType(target) {
    return Object.prototype.toString.call(target).slice(8, -1)
  }

下面进入正题。因为要使用递归,那么传进来的参数,可能是数组,也可能是对象、文本,需要区分。

  • 传进来的 vnode 是数组,那么对数组的每一项,使用 createRealDom 创建节点,并推到 realDom 列表中
  • 传进来的 vnode 是对象或文本,直接创建一个新节点。
    • 如果 vnode 有 children,对 children 递归使用 createRealDom,将返回的结果,使用 DOM API : appendChild 挂载到刚刚创建的新节点上。
  • 返回 realDom 结果
  function createRealDom(vnode) {
    // 可能是对象,也可能是数组
    let realDom = undefined
    if(getType(vnode) === 'Array') {
      // 数组类型
      realDom = []
      for(const item of vnode) {
        realDom.push(createRealDom(item))
      }
    } else {
      // 对象或者文本类型
      realDom = createOneNode(vnode)
      if(vnode.children) {
        // children 返回的是一个列表
        const childlist = createRealDom(vnode.children)
        // 返回孩子列表,并挂载到真实 dom 上
        for(const child of childlist) {
          realDom.appendChild(child)
        }
      }
    } 
    return realDom
  }

这一步,由「虚拟 DOM」得到「真实 DOM」的结果,如下:


Step 4:将 data 渲染到模板中

第 4-5 步,和前面的函数有一些关联,在其关键步骤上(createOneNode函数)加入了 mustache 语法的识别和替换,在获取数据时动态更新相关联的 DOM。

假设模板中存在 mustache 语法,比如 {{xxx}} ,按照 vue 的语法,就是将 xxx 替换为 data.xxx 的值。这里先实现,一段 html 模板字符串(不带标签的,都是 textnode 类型)传入后,识别 {{}} 双括号中的变量,并替换为 data 中相应的值。

  • 正则匹配所有的 {{}} 类型,通过匹配结果得到数据表达式
  • 通过 new Function(with(data) { return ${key}})() 的执行结果,获取表达式执行结果。函数文本中的 with(data) 将数据锁定在 data 中,因此能获取对应的值。
  • 通过字符串 replace 方法,将 匹配结果替换为数据对应 key 的 value
  • 返回新结果
function matchMustache(w, realDom) {
  // 匹配每一对 {{}},并将其中的字符作为 key,匹配并替换 data 中对应 key 的值
  // /\{\{(.*)\}\}/g 这个不行!
  let word = w
  const exp = /\{\{(.+?)\}\}/g
  const matchs = word.match(exp) ?? []
  // 然后还是用正则的替换
  for(const match of matchs) {
    const key = match.slice(2,-2).trim()
    // 使 mustache 支持表达式,点操作
    function getVal(){
      // 为了简化写法,这里的 data 是全局变量
      let res = new Function(`with(data) { return ${key}}`)()
      if(typeof res === 'object'){
        res = JSON.stringify(res)
      }
      return res
    }
    const value = getVal()
    word = word.replace(match, value)
  }
  return word
}

那么,mustache 语法需要在哪个环节进行转换呢? 这里插在了 createOneNode 方法中:因为在简易版本中,mustache 语法只会出现在纯文本节点中。

  function createOneNode (vnode, collect = true) {
    // 如果是文本节点,可能是空的
    const tag = vnode.tagName
    if(tag) return document.createElement(tag)

    const node = document.createTextNode(vnode)
    // 文本节点,可能涉及到 mustache 转换了
    // 如果在这里转化 mustache 语法,会不会更好收集依赖
    
    const replaced = matchMustache(vnode, data, node, collect)
    node.nodeValue = replaced
    return node
  }

Step 5:实现 data 响应式更新

在 vue 中,需要响应式的场景,包括模板渲染、computed选项、watch选项、属性指令等等。为简化,这里先实现模板渲染的响应式更新。 要解决的问题是:

  • 哪个数据被使用
  • 谁使用了数据
  • 怎么通知使用方更新
5.1 哪个数据被使用?

先解决 「哪个数据被使用」 的问题。这里使用 proxy 实现,语法很简单,跟 Object.defineProperty 类似,设置属性对应的 get 和 set 动作就可以了。在 get 和 set 函数中,可知当前访问的 property,也就解决了刚刚的问题。在 get 时收集依赖,set 时通知依赖,是第 2、3步需要解决的问题。

const handler = {
    get(obj, property) {
      track(property)
      return obj[property]
    },
    set(obj, property, value) {
      // 通知之前收集的所有依赖:更新一下相关的值啦!
      obj[property] = value
      notify(property)
      return true
    }
 }
const data = new Proxy(rawData, handler)

由于 proxy 只支持第一层属性的代理,所以遇到属性值为对象时,需要递归的将数据转化为 proxy。

function reactiveData(rawData) {
  // 递归地将数据转化为响应式
  function deepProxy(data) {
    const newData = new Proxy(data, handler)
    for(const key in newData) {
      const val = newData[key]
      if(typeof val === 'object') {
        newData[key] = deepProxy(newData[key])
      }
    }
    return newData
  }
  return deepProxy(rawData)
}
5.2 谁使用了数据?

接着解决 「谁使用了数据」 的问题。这里只考虑模板编译的情况,在 mustache 匹配时会访问 data,而且此时也已知真实 DOM,所以这里将真实 DOM 作为数据使用方。 那么怎么将这个 DOM 节点同步给 proxy 中的 get 函数呢?由于 JS 是单线程的,所以可以断定,在 matchMustache 函数运行时,只在处理某一个 DOM 节点。没法给 get 函数传参,只能将这个 DOM 节点设置为全局变量,就可以在 get 中使用了。 流程如下:

  • 将当前处理节点设置为全局变量 realDomTarget,和对应的原始内容 vnode 也设置为全局变量 wTarget [13-14行]
  • 运行 matchMustache 函数,当文本节点存在 mustache 语法时,会访问 data 中对应的变量, proxy 中对应 property 的 get 函数被触发
  • Get 函数通过 realDomTarget 获得当前处理的 DOM 节点,并将 realDomTarget 添加到 当前 property 的依赖中
  • 全局变量 realDomTarget 、wTarget 恢复为初始值,否则会在其他访问数据的情境下被误用为依赖 [20-21行]
function createOneNode (vnode) {
  // 如果是文本节点,可能没有 tag
  
  const tag = vnode.tagName
  if(tag) {
    const node = document.createElement(tag)
    return node
  }

  const node = document.createTextNode(vnode)
  
  // 将当前处理节点设置为全局变量 realDomTarget
  realDomTarget = node
  wTarget = vnode

  const replaced = matchMustache(vnode)
  node.nodeValue = replaced

  // 全局变量归位
  realDomTarget = null
  wTarget = ''
  
  return node
}

按上述分析,数据中的一个 property 可能对应 多个 realDom,而一个 realDom 也可能使用了 多个 property,是多对多的关系。这里使用 Map 存下每个 property 对应的 realDom 列表,维护这样的关系。

// Dep 结构如下
{
    property1: [ realDom1, realDom2],
    property2: [realDom3, realDom1],
}

在 get 时运行 track 函数,为对应的 property 添加依赖。

function track(property) {
  if(typeof property === 'string' && realDomTarget) {
      if(!Dep[property]) {
        Dep[property] = []
      }
      // 使用 map 减少重复操作
      let target = domTargetMap.get(realDomTarget)
      if(!target) {
        target = { w: wTarget,realDom: realDomTarget }
        domTargetMap.set(realDomTarget, target)
      }
      // 而且是没存储过的依赖
      if(Dep[property].indexOf(target) === -1) {
        Dep[property].push(target)
      }
  }
}

上述代码运行后,收集的依赖结果如下。可以看到鼠标移至 realDom 属性时,页面上对应的 DOM 节点也高亮了;使用 num1 数据的 DOM 节点和原始模板值,都被收集在以 num1 对应的数组中, num2 同理。

5.3 怎么通知使用方更新?

到这里为止,我们已经解决了前两个问题,还剩下 「怎么通知使用方更新」 这个问题了。当数据发生改动时,proxy 中的 set 函数被调用,更新 Dep 中对应 property 的真实 DOM 列表即可。

function notify(property) {
  if(Dep[property]) {
      const deps = Dep[property].slice()
      // Dep[property] = []
      // dom 和 data 是多对多的关系,更新一个,必须更新其他
      for(const dep of deps) {
        const realDom = dep.realDom
        // 重新调用函数计算新的 DOM 节点值
        const replaced = matchMustache(dep.w)
        // 更新nodevalue
        realDom.nodeValue = replaced
      }
    }
}

修改数据前:

修改数据后,数据响应式更新:

---

Step 6:Vue 实例初始化

最后,将前面几步串起来,就可以啦~

let data = undefined
let methods = undefined
const Dep = {} // 用于存放依赖的对象
const domTargetMap = new Map()

function myVue(options) { 
  // 转成全局变量只是为了便于获取,减少代码量
  data = reactiveData(options.data())
  methods = options.methods
  this.data = data
  // 获取模板,编译为虚拟 dom
  const tmpl = options.template[0] === '#' ?  document.querySelector(options.template).innerHTML : options.template
  // 把html字符串转化虚拟dom
  const vnode = genVnode(tmpl)
  // 使用虚拟dom创建真实dom树
  const fragment = document.createDocumentFragment() // fragment用来接收所有的结果,之后统一挂载到应用根节点上
  const realDom = createRealDom(vnode) 
  fragment.appendChild(realDom)

  // 将 fragment 挂载到 app 节点上
  const app = document.querySelector(options.el || '#app')
  app.appendChild(fragment)
}

后记

这部分代码书写,并没有完全参照源码,一是源码太复杂,没看完,没看懂 QAQ;二是本次主要想探索下前端框架原理,重在「可用」和「简洁」,所以实现了功能就没太纠结于 vue 源码了。后续会继续研读源码,继续改进 ~

🔗 代码链接:github.com/dandanDQ/bl…

🔗 使用示例: jsbin.com/niyapom