Vue源码解析,模拟Vue的执行流程,实现一个简易的Vue

关于源码的部分总结
  • 编译的重要性:首先vue模板中的很多语法html是不能识别的,例如插值表达式、指令等,其次我们通过编译的过程可以进行依赖收集,依赖收集后 data 中的数据模型就跟数据产生了绑定关系,当数据模型发生变化就可以通知依赖做更新,最终实现模型驱动视图变化
  • 双向绑定的原理:双向绑定是指在 input 元素上使用 v-model 指令,在编译时解析 v-model 然后给当前元素加上事件监听,将 v-model 的回调函数作为 input 的回调函数,如果input 发生变化就可以更新对应值,值又是绑定在 vue 实例上的,实例同时又做了数据响应化也就是数据劫持,会触发他的setter 函数,然后通知对应依赖进行更新
相关方法的理解
  • Object.defineProperty() 直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象
  • Document.createDocumentFragment 创建一个新的空白的文档片段
1. vue 整体执行流程
1. new Vue()
    1.1 通常new Vue()之后,Vue会调用进行初始化,初始化生命周期、事件、props、methods、data、computed、watch等
    1.2 在beforeCreate之后、create之前,data数据的劫持、属性代理
        - 主要是通过Object.defineProperty给每个属性添加Dep实例,负责收集与该属性有依赖关系的Watcher(这个Dep实例可以存储当前属性的依赖、添加依赖、通知属性更新)
        - 将属性定义到当前创建的vue实例上,同时创建属性的setter、getter函数,实时获取与修改实例中的data数据

2. 调用$mount挂载组件

3. compile编译(对template模板进行扫描) 
    3.1 转换根节点内容为DocumentFragment对象
        - 通过document.createDocumentFragment创建空白文档片段,将根节点中的所有子元素添加至文档片段中,然后编译DocumentFragment对象
    3.2 正则解析vue的指令,形成语法树(AST)
        - 遍历所有子节点,分别匹配元素节点(v-、@、:开头的)、文本节点(插值表达式)
    3.3 标记静态节点,用于性能优化
        - 匹配对应节点后,调用对应的更新函数,更新node节点的值,同时创建依赖(也就是watcher),一个节点属性对应一个watcher
        - 创建依赖时,会将依赖存入每个属性的Dep实例中(同一个变量在模板中使用几次,对应属性的Dep实例数组中就保存几个watcher)
        - 同时创建属性的setter、getter函数,在访问和修改对象属性时,更新对应的依赖
    3.4 将编译后的结果追加至根节点
    3.5 generate 将AST转为渲染函数render function

4. render function
    4.1 生成虚拟DOM树,后期改变数据改变的其实都是虚拟DOM上的数据,在更新前会做diff算法的比较,通过计算后执行最小更新,这个流程是用js计算时间换来了更少的dom操作,核心目的就是减少浏览器的页面渲染次数和数量
    4.2 依赖收集,通过数据劫持、观察者模式,发现数据发生变化,然后找到对应的DOM节点进行更新
2. 实现数据响应(数据劫持)部分,利用defineProperty对属性进行读取、改变操作时,会执行属性对应的 gettersetter 函数
// 写个小例子感受下defineProperty,在obj对象上定义一个新属性
// 设置属性的getter、setter函数

// defineProperty.html
<body>
  <div id="app">
    <p id="name"></p>
  </div>

  <script>
    let obj = {}
    Object.defineProperty(obj,'name', {
      get () {
        return document.querySelector('#name').innerHTML
      },
      set (val) {
        document.querySelector('#name').innerHTML = val
      }
    })
    obj.name = 'Asher'
  </script>
</body>
下面实现数据响应部分(数据劫持)
// 先约定好使用方法,和vue一样
new Vue({
  el: '',
  data: {},
  methods: {}
})
// svue.js
class SVue {
  constructor (options) {
    this.$options = options

    // 数据响应
    this.$data = options.data
    this.observe(this.$data)
  }
  
  observe (val) {
    if(!val || typeof val !== 'object') return
    // 遍历data的属性
    Object.keys(val).forEach(key => {
        this.defineReactive(val, key, val[key])
    })
  }

  // 数据响应化函数(数据劫持)
  defineReactive (obj, key, val) {
    Object.defineProperty (obj, key, {
      get () {
        return val
      },
      set (newVal) {
        if(val === newVal) return
        val = newVal
        console.log(`${key}属性更新了:${val}`)
      }
    })
  }
}
// index.html
// 执行当前文件后,发现只打印了一个'属性更新了'
// 因为data属性中,有一个foo属性的值是一个对象,因此需要考虑深度遍历的问题
<script src="svue.js"></script>
<script>
    const app = new SVue({
      data: {
        test: 'I am test',
        foo: {
          bar: 'bar'
        },
        name: 'Asher'
      }
    })
    app.$data.test = 'hello, SVue'   // test属性更新了:hello, SVue
    app.$data.foo.bar = 'oh bar'
</script>
// svue.js
// 修改数据响应化函数,添加递归
defineReactive (obj, key, val) {
  // 添加递归 解决数据嵌套
    this.observe(val)

    Object.defineProperty (obj, key, {
      get () {
        return val
      },
      set (newVal) {
        if(val === newVal) return
        val = newVal
        console.log(`${key}属性更新了:${val}`)
      }
    })
  }
}
4. 依赖收集:遍历模板( template )进行依赖收集,在更新数据时,只有能匹配到有对应依赖时才会做数据更新
// 通过一个简单例子理解下依赖收集
// 在下面这个例子中,data数据中有name1、name2和name3,但实际在元素中使用并展示的数据只有name1、name2,而且name1使用2次,但是在created函数中又更新了name1和name3
// 这时的内部执行机制是先进行视图依赖收集,保存视图中有哪些地方对数据有依赖,这样当数据变化时,直通知对应依赖即可,没有依赖的则不做更新
new Vue({
  template:
    `
    <div>
      <span>{{name1}}</span>
      <span>{{name2}}</span>
      <span>{{name1}}</span>
    </div>
    `,
    data: {
      name1: 'name1',
      name2: 'name2',
      name3: 'name3'
    },
    created () {
      this.name1 = 'Asher',
      this.name3 = 'Andy'
    }
})
实现依赖收集、观察者部分,针对 data 中的每个属性创建每个对应的依赖,当属性在页面中出现几次,那么就有几个 watcher,这个过程大概就是每个依赖有自己各自的 watcher,依赖对自己的 watcher 进行管理
// svue.js
// 用来管理watcher
class Dep {
  constructor () {
    // 存放若干依赖(watcher,一个watcher对应一个属性)
    this.deps = []
  }

  // 添加依赖
  addDep (dep) {
    this.deps.push(dep)
  }

  // 通知watcher,更新依赖
  notify () {
    this.deps.forEach(dep => dep.update())
  }
}

// watcher
class Watcher {
  constructor () {
    // 将当前watcher实例指向Dep静态属性target
    Dep.target = this
  }

  update () {
    console.log('属性更新了')
  }
}
模拟依赖创建(注意这里只是模拟!模拟!new Watcher()并不在这里调用)
// 在构造函数中模拟watcher创建,修改数据劫持部分
  constructor (options) {
    this.$options = options

    // 数据响应
    this.$data = options.data
    this.observe(this.$data)

    // 模拟watcher创建,每改变一个属性就要创建一个watcher
    new Watcher()
    this.$data.test;
    new Watcher()
    this.$data.foo.bar;
  }

  // data属性响应式
  defineReactive (obj, key, val) {
    // 递归 解决数据嵌套
    this.observe(val)

    // 初始化 Dep
    const dep = new Dep()

    Object.defineProperty(obj, key, {
      get () {
        Dep.target && dep.addDep(Dep.target)
        return val
      },
      set (newVal) {
        if(val === newVal) return
        val = newVal
        dep.notify()
      }
    })
  }
5. 编译:在vue中使用的插值表达式、v-指令@事件 等等,这些标识,html 都不认识
使用 document.createDocumentFragment() 创建空白文档片段,主要用于将元素附加到该文档上,然后将文档片段附加到DOM树,这样不会引起页面回流,避免频繁操作DOM更新页面
// compile.js
class Compile {
  // 当前遍历元素 当前vue实例
  constructor (el, vm) {
    // 要遍历的节点
    this.$el = document.querySelector(el)
    this.$vm = vm

    if(this.$el) {
      // 转换内部内容为Fragment
      this.fragment = this.node2Fragment(this.$el)
      // 执行编译
      this.compile(this.fragment)
      // 将编译后的结果追加至$el
      this.$el.appendChild(this.fragment)
    }
  }

  // 编译
  compile (el) {
    const childsNodes = el.childNodes
    Array.from(childsNodes).forEach(node => {
      if(this.isElement(node)) {
        // 元素节点
        console.log('编译元素' + node.nodeName)
      }else if(this.isInterpolation(node)) {
        // 文本节点 插值格式
        console.log('编译文本' + node.textContent)
      }

      // 递归子节点
      if(node.childNodes && node.childNodes.length > 0) {
        this.compile(node)
      }
    })
  }

  node2Fragment (el) {
    const frag = document.createDocumentFragment()
    // 将el中所有子元素移至frag中
    let child;
    while(child = el.firstChild) {
      frag.appendChild(child)
    }
    return frag
  }

  // 元素子节点
  isElement (node) {
    return node.nodeType === 1
  }

  // 文本子节点 插值表达式{{}}
  isInterpolation (node) {
    return node.nodeType === 3 && /\{\{(.*)\}\}/.test(node.textContent)
  }
}
修改之前的文件,测试一下编译部分的逻辑
// index.html

<div id="app">
   <p>{{name}}</p>
   <p s-text="name"></p>
   <p @click="handleClick">点击更换文案</p>
   <input type="text" s-model="name">
</div>

<script src="svue.js"></script>
<script src="compile.js"></script>
// svue.js
  constructor (options) {
    this.$options = options

    // 数据响应
    this.$data = options.data
    this.observe(this.$data)

    // 编译
    new Compile(options.el, this)
  }
根据 index.html 结构可以得到打印结果,以及页面展示
编译-区分节点.jpg
完成元素节点的匹配,下面就是针对各种类型的节点做不同处理,例如文本节点插值表达式,我们要根据使用的属性在 data 中获取到对应的值,然后才能正确的展示在页面上
// compile.js
// 编译文本节点
compileText (node) {
   // RegExp.$1 获取正则表达式中第一个()分组匹配到的值
   // 将变量值复制给DOM并展示
   node.textContent = this.$vm.$data[RegExp.$1]
}
编译-匹配插值表达式.jpg
完成部分编译之后,每编译一部分内容就要绑定对应的依赖,要不然我们是没办法做到依赖监听和及时更新的,数据也只会初始化一次,我们来测试下现在没有绑定依赖的情况(参照如下文件的修改)
// index.html
<div id="app">
   <p>{{name}}</p>
   <p s-text="name"></p>
   <p @click="handleClick">点击更换文案</p>
   <input type="text" s-model="name">
</div>

  <script>
    const app = new SVue({
      el: 'app',
      data: {
        test: 'I am test',
        foo: {
          bar: 'bar'
        },
        name: 'Asher'
      },
      // 设置生命周期以及延时执行修改name
      created () {
        console.log('created执行了')
        setTimeout(() => {
          console.log('created中的setTimeout执行了')
          this.name = 'created中修改name属性'
        }, 1500)
      },
    })
  </script>
// svue.js
  constructor (options) {
    this.$options = options

    // 数据响应
    this.$data = options.data
    this.observe(this.$data)

    // 编译
    new Compile(options.el, this)

    // 匹配created 修改this绑定
    if(options.created) {
      options.created.call(this)
    }
  }
从下面打印的结果可以看出,created 和延时函数都执行了,但页面展示的结果并没有改变,说明数据并未更新
编译-未绑定依赖.jpg
下一步,处理属性代理,添加更新逻辑,属性代理是将属性绑定到当前vue实例上,为了方便在访问属性时可以直接通过 this.data 访问
// svue.js
class SVue {
  // 观察者
  observe (val) {
    if(!val || typeof val !== 'object') return
    // 遍历data的属性
    Object.keys(val).forEach(key => {
        this.defineReactive(val, key, val[key])
        // 代理data中的属性到vue实例上
        this.proxyData(key)
    })
  }

  proxyData (key) {
    // this 指当前vue实例
    Object.defineProperty(this, key, {
      get () {
        return this.$data[key]
      },
      set (newVal) {
        this.$data[key] = newVal
      }
    })
  }
}
// compile.js
// 通用更新函数 参数:节点、实例、表达式、指令(区分文本、事件等,方法名前缀)
update (node, vm, exp, dir) {
    const updaterFn = this[dir + 'Updater']
    // 初始化
    updaterFn && updaterFn(node, vm[exp])
    // 依赖收集
    new Watcher(vm, exp, function(val){
      updaterFn && updaterFn(node, val)
    })
}

textUpdater (node, val) {
   node.textContent = val
}

// 修改 compileText 方法
compileText (node) {
   this.update(node, this.$vm, RegExp.$1, 'text')
}
// svue.js
// 修改 Watcher 类
// watcher
class Watcher {
  constructor (vm, key, callback) {
    this.vm = vm
    this.key = key
    this.callback = callback
    // 将当前watcher实例指向Dep静态属性target
    Dep.target = this
    // 触发getter 添加依赖
    this.vm[this.key]
    // 置空 避免重复添加
    Dep.target = null
  }

  update () {
    // console.log('属性更新了')
    this.callback.call(this.vm, this.vm[this.key])
  }
}
编译-绑定依赖后 延时更新成功.jpg
接着补充编译部分对指令、事件等的识别和处理
// compile.js
// 编译
compile (el) {
    const childsNodes = el.childNodes
    Array.from(childsNodes).forEach(node => {
      if(this.isElement(node)) {
        // 元素节点
        console.log('编译元素' + node.nodeName)
        // 查找s-、@、:开头的
        const nodeAttrs = node.attributes
        Array.from(nodeAttrs).forEach(attr => {
          // 属性名
          const attrName = attr.name
          // 属性值
          const exp = attr.value
          if(this.isDirective(attrName)) {
            const dir = attrName.substring(2)
            this[dir] && this[dir](node, this.$vm, exp)
          }
          if(this.isEvent(attrName)) {
            const dir = attrName.substring(1)
            this.eventHandler(node, this.$vm, exp ,dir)
          }
        })
      }else if(this.isInterpolation(node)) {
        // 文本节点 插值格式
        console.log('编译文本' + node.textContent)
        this.compileText(node)
      }

      // 递归子节点
      if(node.childNodes && node.childNodes.length > 0) {
        this.compile(node)
      }
    })
}

// 指令
isDirective (attr) {
   return attr.indexOf('s-') == 0
}

// 事件
 isEvent (attr) {
   return attr.indexOf('@') == 0
}

// 事件处理 从methods找出对应方法
eventHandler (node, vm, exp, dir) {
    let fn = vm.$options.methods && vm.$options.methods[exp]
    if(dir && fn) {
      node.addEventListener(dir, fn.bind(vm))
    }
}

text (node, vm, exp) {
   this.update(node, vm, exp, 'text')
 }
下面可以添加一个事件测试一下
// index.html
<div id="app">
   <p>{{name}}</p>
   <p s-text="name"></p>
   <p @click="handleClick">点击更换文案</p>
   <input type="text" s-model="name">
</div>

methods: {
  handleClick () {
    this.name = 'Click'
  }
}
事件绑定.jpg
最后,补充双向绑定的部分,通过指令 s-modle 使用
// compile.js
  module (node, vm, exp) {
    // 指定input的value属性
    this.update(node, vm, exp, 'model')
    node.addEventListener('input', e => {
      vm[exp] = e.target.value
    })
  }

  moduleUpdater (node, value) {
    node.value = value
  }
  // 编译
  compile (el) {
    const childsNodes = el.childNodes
    Array.from(childsNodes).forEach(node => {
      if(this.isElement(node)) {
        // 元素节点
        console.log('编译元素' + node.nodeName)
        // 查找s-、@、:开头的
        const nodeAttrs = node.attributes
        Array.from(nodeAttrs).forEach(attr => {
          // 属性名
          const attrName = attr.name
          // 属性值
          const exp = attr.value
          if(this.isDirective(attrName)) {
            const dir = attrName.substring(2)
            this[dir] && this[dir](node, this.$vm, exp)
          }
          if(this.isEvent(attrName)) {
            const dir = attrName.substring(1)
            this.eventHandler(node, this.$vm, exp ,dir)
          }
        })
      }else if(this.isInterpolation(node)) {
        // 文本节点 插值格式
        console.log('编译文本' + node.textContent)
        this.compileText(node)
      }

      // 递归子节点
      if(node.childNodes && node.childNodes.length > 0) {
        this.compile(node)
      }
    })
  }
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 201,468评论 5 473
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,620评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,427评论 0 334
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,160评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,197评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,334评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,775评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,444评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,628评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,459评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,508评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,210评论 3 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,767评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,850评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,076评论 1 258
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,627评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,196评论 2 341

推荐阅读更多精彩内容