vue原理解析(二):vue处理数组,实现computed, 实现模板编译

2,447 阅读2分钟

实现对数组的劫持

上篇文章已经说过,Object.defineProperty是无法实现对数组的劫持的,那么来说,vue是采用什么办法来解决这个问题的呢?vue是新建了一个数组的原型对象,这个对象的原型指向Array.prototype。具体实现代码如下:

const arrayProto = Object.create(Array.prototype)
let methodName = ['push', 'pop', 'shift', 'unshift', 'reverse', 'sort', 'splice']

methodName.forEach(method => {
  arrayProto[method] = function(...args) {
  // array 的push的元素可能是一个对象, 使用这种方法处理数组中元素是对象的情况为响应式的
    if(method === 'push') {
      this.__ob__.observeArray(args)
    }
    const result = Array.prototype[method].apply(this, args)
    this.__ob__.dep.notify()
    return result
  }
})
// 然后在上文的Observer方法中判断当前的监听的对象是否是数组,对数组的情况进行特殊处理
class Observer {
  constructor(data) {
    this.dep = new Dep()
    if(Array.isArray(data)) {
      data.__proto__ = arrayProto
      this.observeArray(data)
    } else {
      this.walk(data)
    }
    Object.defineProperty(data, '__ob__', {
      value: this,
      enumerable: false,
      writable: true,
      configurable: true
    })
  }
// 将数组中元素为对象的数据,处理成响应式的
  observeArray(arr) {
    for(let i = 0;i < arr.length;i++) {
      observe(arr[i])
    }
  }
  ...
}

实现computed

熟悉vue的同学应该都很清楚了,computed计算属性,会依赖data中的数据,当data中的数据发生变化的时候,计算属性会自动更新,同时computed中的属性是不能手动设置的。且应该具有惰性和缓存两大优势。另外关于computed的使用:尤大大在官网中也提到了:vue模板中的表达式设计的初衷是用于简单计算的,模板中涉及到的复杂逻辑运算都应该放到计算属性中。接下来我们就开始实现一个computed属性了:

class MVue{
  constructor(){
    this.ininComputed()
    // watcher能监听computed中的属性
    this.initWatcher()
 }
 ...
  initComputed() {
    let computed = this.$options.computed
    if(computed) {
      Reflect.ownKeys(computed).forEach(watcher => {
        const compute = new Watcher(this, computed[watcher], () => {})

        Object.defineProperty(this, watcher,{
          enumerable: true,
          configurable: true,
          get: function computedGetter() {
            compute.get()
            return compute.value
          },
          set: function computedSetter() {
            console.warn('计算属性不可赋值')
          }
        })
      })
    }
  }
}

上面仅仅是简单实现了一个computed,但是我们都知道computed属性有两个特点:

    1. 计算属性是惰性的:计算属性依赖的其他属性发生变化的时候,计算属性并不会立即重新执行,要等到获取的时候才会去执行。
    1. 计算属性是缓存的:如果计算属性依赖的其他属性没有变化的时候,即使重新对计算属性求值,亦不会重新计算。
class Watcher {
  constructor(vm, exp, cb, options = {}) {
    this.lazy = this.dirty = !!options.lazy
    this.vm = vm
    this.exp = exp
    this.cb = cb
    this.id = ++watchId
    if(!this.lazy) {
      this.get()
    }
  }

  get() {
    Dep.target = this
    if(typeof this.exp === 'function'){
      this.value = this.exp.apply(this.vm)
    } else {
      this.value = this.vm[this.exp]
    }
    Dep.target = null
  }
  // 当lazy 为true的时候 run方法并不执行 实现惰性
  update() {
    // 是惰性的 就不要去执行
    if(this.lazy) {
      this.dirty = true
    } else {
      this.run()
    }
  }

  run() {
    if(watcherQueue.includes(this.id)) {
      return
    }
    watcherQueue.push(this.id)
    Promise.resolve().then(() =>{
      this.cb.call(this.vm)
      watcherQueue.pop()
    })
  }
}
// 改造initComputed如下
 // 初始化计算属性
  initComputed() {
    let computed = this.$options.computed
    if(computed) {
      Reflect.ownKeys(computed).forEach(watcher => {
        const compute = new Watcher(this, computed[watcher], () => {}, { lazy: true })
        Object.defineProperty(this, watcher,{
          enumerable: true,
          configurable: true,
          get: function computedGetter() {
          // 如果compute是脏值的话 触发get方法 实现缓存!
            if(compute.dirty) {
              compute.get()
              compute.dirty = false
            }
          // 否则直接返回上次读取的结果
            return compute.value
          },
          set: function computedSetter() {
            console.warn('计算属性不可赋值')
          }
        })
      })
    }
  }

模板编译

简单实现vue的模板编译,我们可以使用

new Watcher(this, () => {
    document.querySelector('#app').innerHTML = `<p>${this.name}</p>`
}, ()=>{})

1.但是这样实现是可以使用模板语法的,需要把模板进行一些处理,最终转换成一个执行dom更新的函数 2.直接替换所有dom的开销很大,最好还是按需更新dom。

为了尽量减少不必要的dom操作和实现跨平台的特性,vue中引入了 Virtual-DOM 即虚拟DOM

什么是vdom? 其实就是一个js对象,用来描述dom长什么样的。

为了得到当前实例的VDOM,每个实例需要有一个render函数来生成VDOM,被称为渲染函数

vue实例如果传入了dom或者template,首先就是要把模板字符串转化成渲染函数,这个过程就是编译

Vue编译原理

  • 1.将模板字符串转换成element AST解析器)
  • 2.对AST进行静态节点标记,用来做VDOM的渲染优化
  • 3.使用element ASTs生成render函数代码字符串 注:AST是一种代码转换成另一种代码,是对源代码的描述。 vue会将template中结构当成一个字符串来处理,这里,我将模拟vue中模板编译来实现元素节点的parser,具体实现如下:(经过parser之后生成一个AST结构) 原理:
// 解析器
function parser(html){
  let stack = []
  let root
  let currentParent
  while (html) {
    let index = html.indexOf('<')
    // 前面还有文本节点
    if (index > 0) {
      let text = html.slice(0, index)
      const element = {
        parent: currentParent,
        type: 3,
        text
      }
      currentParent.children.push(element)
      // 截取html为除文本节点以外的剩余的html
      html = html.slice(index)
    } else if( html[index + 1] !== '/' ) {
      // 前面没有文本节点 且是开始标签
      let gtIndex = html.indexOf('>')
      const element = {
        type: 1,
        tag: html.slice(index + 1, gtIndex),
        parent: currentParent,
        children: []
      }
      if(!root) {
        root = element
      } else {
        currentParent.children.push(element)
      }
      stack.push(element)
      currentParent = element
      html = html.slice(gtIndex + 1)
    } else {
      // 结束标签
      let gtIndex = html.indexOf('>')
      stack.pop()
      currentParent = stack[stack.length - 1]
      html = html.slice(gtIndex + 1)
    }
  }
  return root
}

// 解析一个文本节点
function parseText(text) {
  let originText = text
  let type = 3
  let tokens = []
  while(text) {
    let start = text.indexOf('{{')
    let end = text.indexOf('}}')
    if(start !== -1 && end !== -1) {
      type = 2
      if(start > 0) {
        tokens.push(JSON.stringify(text.slice(0, text)))
      }
      let exp = text.slice(start + 2, end)
      tokens.push(`_s(${exp})`)
      text = text.slice(end + 2)
    } else {
      tokens.push(JSON.stringify(text))
      text = ''
    }
  }
  let element = {
    text: originText,
    type
  }
  if(type === 2) {
    element.expression = tokens.join('+')
  }
  return element
}

经过parser生成AST后需要把AST转换成渲染函数 步骤:

  • 1.递归AST,遇到元素节点则生成如下结构_c(标签名, 属性对象, 后代数组)
  • 2.遇到文本节点,如果是纯文本,则生成如下结构_v(文本字符串)
  • 3.遇到带变量的文本节点,则生成_v(_s(变量名))
  • 4.为了让变量能正常取到,生成最后一个字符串包一层with(this)
  • 5.最后把字符串作为函数生成一个函数,挂载到vm.$options上 方法练习可结合vue中render做比较,若要查看vue中render函数可在 vm.$option.render.call(vm)查看。
// type: 1元素节点 2带变量的文本节点 3纯文本节点
// 核心方法 将AST转换成render函数
function generate(ast) {
  let code = genElement(ast)
  return {
    render: `with(this)${code}`
  }
}
// 转换元素节点
function genElement(el) {
  let children = genChildren(el)
  return `_c(${JSON.stringify(el.tag)}, {}, ${children})`
}
// 遍历后代节点
function genChildren(el){
  if(el.children.length) {
    return '['+ el.children.map(child => genNode(child)) +']'
  }
}
// 转换文本节点
function genText(node) {
  if(node.type === 2) {
    return `_v(${node.expression})`
  } else if( node.type === 3 ) {
    return `_v(${JSON.stringify(node.text)})`
  }
}
// 转换节点
function genNode(node) {
  // 元素节点
  if(node.type === 1) {
    return genElement(node)
  } else {
    return genText(node)
  }
}

下期内容会继续实现一个vdom, 并且会附带一些vue常见原理题的解析~