https://www.bilibili.com/video/BV1em4y1Z7BM

1.png

Object.defineProperty介绍

  1. Object.defineProperty(obj, prop, descriptor)
  2. obj:必需。目标对象
  3. prop:必需。需定义或修改的属性的名字
  4. descriptor:必需。目标属性所拥有的特性
  • value:被定义的属性的值,默认为undefined
  • writable:是否可以被重写,true可以重写,false不能重写,默认为false。
  • enumerable:是否可以被枚举(使用for…in或Object.keys())。设置为true可以被枚举;设置为false,不能被枚举。默认为false。
  • configurable:是否可以删除目标属性或是否可以再次修改属性的特性(writable, configurable, enumerable)。设置为true可以被删除或可以重新设置特性;设置为false,不能被可以被删除或不可以重新设置特性。默认为false。

存取器getter/setter

注意:当使用了getter或setter方法,不允许使用 writable 和 value 这两个属性

  • getter: 当访问该属性时,该方法会被执行。函数的返回值会作为该属性的值返回。
  • setter: 当属性值修改时,该方法会被执行。该方法将接受唯一参数,即该属性新的参数值。
  1. var obj = {};
  2. var initValue = 'hello';
  3. Object.defineProperty(obj,"newKey",{
  4. get:function (){
  5. //当获取值的时候触发的函数
  6. return initValue;
  7. },
  8. set:function (value){
  9. //当设置值的时候触发的函数,设置的新值通过参数value拿到
  10. initValue = value;
  11. }
  12. });
  13. //获取值
  14. console.log( obj.newKey ); //hello
  15. //设置值
  16. obj.newKey = 'change value';
  17. console.log( obj.newKey ); //change value

不要在getter中再次获取该属性值,也不要在setter中再次设置改属性,否则会栈溢出!

实现数据代理

  1. class Vue {
  2. constructor(options) {
  3. this.$options = options
  4. this.initData()
  5. }
  6. initData() {
  7. let data = this._data = this.$options.data
  8. let keys = Object.keys(data)
  9. for (let i = 0; i < keys.length; i++) {
  10. Object.defineProperty(this,keys[i],{
  11. enumerable:true,
  12. configurable:true,
  13. get:function proxyGetter() {
  14. return this._data[keys[i]]
  15. },
  16. set:function proxySetter(value) {
  17. this._data[keys[i]] = value
  18. }
  19. })
  20. }
  21. }
  22. }

实现数据劫持

  1. class Vue {
  2. constructor(options) {
  3. this.$options = options
  4. this.initData()
  5. }
  6. initData() {
  7. let data = this._data = this.$options.data
  8. let keys = Object.keys(data)
  9. for (let i = 0; i < keys.length; i++) {
  10. Object.defineProperty(this,keys[i],{
  11. enumerable:true,
  12. configurable:true,
  13. get:function proxyGetter() {
  14. return this._data[keys[i]]
  15. },
  16. set:function proxySetter(value) {
  17. this._data[keys[i]] = value
  18. }
  19. })
  20. }
  21. for (let i = 0; i < keys.length; i++) {
  22. let value = data[keys[i]]
  23. Object.defineProperty(data,keys[i],{
  24. enumerable:true,
  25. configurable:true,
  26. get:function reactiveGetter() {
  27. console.log(`获取data${keys[i]}值`);
  28. return value
  29. },
  30. set:function reactiveSetter(val) {
  31. console.log(`data${keys[i]}发生了改变`);
  32. value = val
  33. }
  34. })
  35. }
  36. }
  37. }

实现数据递归劫持

将数据响应式的代码抽离到工厂函数中,并且新定义一个Observer类,为后续工作做铺垫

  1. class Vue {
  2. constructor(options) {
  3. this.$options = options
  4. this.initData()
  5. }
  6. initData() {
  7. let data = this._data = this.$options.data
  8. let keys = Object.keys(data)
  9. for (let i = 0; i < keys.length; i++) {
  10. Object.defineProperty(this,keys[i],{
  11. enumerable:true,
  12. configurable:true,
  13. get:function proxyGetter() {
  14. return this._data[keys[i]]
  15. },
  16. set:function proxySetter(value) {
  17. this._data[keys[i]] = value
  18. }
  19. })
  20. }
  21. observe(data)
  22. }
  23. }
  24. function observe(data) {
  25. let type = Object.prototype.toString.call(data)
  26. if(type !== '[object Object]' && type !== '[object Array]'){
  27. return
  28. }
  29. new Observer(data)
  30. }
  31. function defineReactive(obj,key,val) {
  32. //如果被观测的值是对象,则递归
  33. observe(val)
  34. Object.defineProperty(obj,key,{
  35. enumerable:true,
  36. configurable:true,
  37. get:function reactiveGetter() {
  38. console.log(`获取data${key}值`);
  39. return val
  40. },
  41. set:function reactiveSetter(newval) {
  42. console.log(`data${key}发生了改变`);
  43. val = newval
  44. }
  45. })
  46. }
  47. class Observer {
  48. constructor(data){
  49. this.walk(data)
  50. }
  51. walk(data){
  52. let keys = Object.keys(data)
  53. for (let i = 0; i < keys.length; i++) {
  54. defineReactive(data,keys[i],data[keys[i]])
  55. }
  56. }
  57. }

实现Watcher

由于模板涉及到vue的编译和vdom等知识,所以先用watch选项与$watch api 来测试对属性的监听

问题一:首先,回调函数肯定不能硬编码在setter

因此,我们每个属性需要有个自己的“”,不管是使用watch选项初始化还是使用$watchapi来监听某个属性时,我们需要把这些回调添加到这个”筐”中,等到属性setter触发时,从“筐”中把收集的回调拿出来通知(notify)他们执行

问题二:有可能存在同一个回调可能依赖多个属性,例如模板或者computed

因此,我们可以使用对属性求值,来触发相应的getter,在getter中让“筐”去找当前的回调(depend),并且收集它

问题三:“筐”去哪里找当前的回调?

我们可以把当前需要被收集的回调在触发getter之前存在一个公共的地方,触发后再从公共的地方移除。就像从一个舞台上台再下台的过程。

根据面向对象的编程思想,我们把”筐”抽象成一个Dep类的实例,把回调抽象成一个Watcher类的实例

  1. class Vue {
  2. constructor(options) {
  3. this.$options = options
  4. // TODO:data可能是函数
  5. this._data = options.data
  6. this.initData()
  7. this.initWatch()
  8. }
  9. initData() {
  10. let data = this._data
  11. let keys = Object.keys(data)
  12. for (let i = 0; i <keys.length; i++) {
  13. Object.defineProperty(this,keys[i],{
  14. enumerable:true,
  15. configurable:true,
  16. get:function proxyGetter() {
  17. return data[keys[i]]
  18. },
  19. set:function proxySetter(value) {
  20. data[keys[i]] = value
  21. }
  22. })
  23. }
  24. observe(data)
  25. }
  26. initWatch() {
  27. let watch = this.$options.watch
  28. let keys = Object.keys(watch)
  29. for (let i = 0; i < keys.length; i++) {
  30. this.$watch(keys[i],watch[keys[i]])
  31. }
  32. }
  33. $watch(exp,cb) {
  34. new Watcher(this,exp,cb)
  35. }
  36. }
  37. function observe (data) {
  38. let type = Object.prototype.toString.call(data)
  39. if(type !== '[object Object]' && type !== '[object Array]'){
  40. return
  41. }
  42. new Observer(data)
  43. }
  44. class Observer {
  45. constructor(data){
  46. this.walk(data)
  47. }
  48. walk(data){
  49. let keys = Object.keys(data)
  50. for (let i = 0; i < keys.length; i++) {
  51. defineReactive(data,keys[i],data[keys[i]])
  52. }
  53. }
  54. }
  55. function defineReactive(obj, key, val) {
  56. observe(obj[key])
  57. let dep = new Dep()
  58. Object.defineProperty(obj,key,{
  59. enumerable:true,
  60. configurable:true,
  61. get:function reactiveGetter() {
  62. if(Dep.target){
  63. dep.depend()
  64. }
  65. return val
  66. },
  67. set:function reactiveSetter(value) {
  68. if(val === value){
  69. return
  70. }
  71. dep.notify()
  72. val = value
  73. }
  74. })
  75. }
  76. class Dep {
  77. constructor() {
  78. this.subs = []
  79. }
  80. depend() {
  81. if(Dep.target){
  82. this.subs.push(Dep.target)
  83. }
  84. }
  85. notify(){
  86. this.subs.forEach(watcher => {
  87. watcher.run()
  88. })
  89. }
  90. }
  91. class Watcher {
  92. constructor(vm,exp,cb) {
  93. this.vm = vm
  94. this.exp = exp
  95. this.cb = cb
  96. this.get()
  97. }
  98. get() {
  99. Dep.target = this
  100. this.vm[this.exp]
  101. Dep.target = null
  102. }
  103. run() {
  104. this.cb.call(this.vm)
  105. }
  106. }

实际在Vue中,watcher实例的求值和调用回调函数是异步调用的,并且在上一个事件循环中无论改变几次属性,回调只会异步调用一次,所以我们对Watcher及run方法进行改造:

  1. let watcherQueue = []
  2. let watcherId = 0
  3. class Watcher {
  4. constructor(vm,exp,cb) {
  5. this.vm = vm
  6. this.exp = exp
  7. this.cb = cb
  8. this.get()
  9. this.id = ++watcherId
  10. }
  11. get() {
  12. Dep.target = this
  13. this.vm[this.exp]
  14. Dep.target = null
  15. }
  16. run() {
  17. if(watcherQueue.indexOf(this.id) !== -1){
  18. return
  19. }
  20. watcherQueue.push(this.id)
  21. let index = watcherQueue.length - 1
  22. Promise.resolve().then(()=>{
  23. this.cb.call(this.vm)
  24. watcherQueue.splice(index,1)
  25. })
  26. }
  27. }

至此,我们实现了一个基本的基于发布订阅的Dep和Watcher,但是目前仍然存在以下问题:

  1. 对象新增属性仍然无法触发回调
  2. 数组仍然没有做处理,如果使用Object.defineProperty对数组进行劫持会存在以下问题:
  • 改变了数组的顺序、改变了数组的长度、或者删除了数据。数组的下标全乱了。这时候怎么办?Object.defineProperty(array, '0', {}); 我们这个定义到底谁是谁?
  • 数组的原生方法进行增删改查无法触发回调
  • 与对象相似,对一个不存在的下标赋值也无法触发

实现$set方法

对象上的__ob__是用来干什么的?

实际上__ob__就是Observer对象,并且对象上存储了一个Dep实例

2.png

可以看到,这个dep是和之前defineReactive闭包中的“筐”不同的另外一个“筐”,当属性的值是一个对象时,把触发getterwatcher也收集了一份在自己的subs中,这样就方便我们之后通过代码命令式地去触发这个属性对象的watcher

所以$set方法的实现思路基本如下:

  1. 在创建Observer实例时,也创建一个新的”筐”,挂在Observer实例上,然后把Observer实例挂载到对象的__ob__属性上。
  2. 触发getter时,不光把watcher收集一份到之前的“筐里”,也收集一份在这个新的”筐”里。
  3. 用户调用$set时,手动地触发__ob__.dep.notify()
  4. 最后别忘了在notify()之前调用defineReactive把新的属性也定义成响应式

关键代码如下:

  1. class Vue {
  2. //...
  3. $set(target,key,value) {
  4. let ob = target.__ob__
  5. defineReactive(target,key,value)
  6. ob.dep.notify()
  7. }
  8. }
  9. class Observer {
  10. constructor(data){
  11. this.dep = new Dep()
  12. this.walk(data)
  13. Object.defineProperty(data,'__ob__',{
  14. enumerable:false,
  15. configurable:false,
  16. value:this,
  17. writable:true,
  18. })
  19. }
  20. //...
  21. }
  22. function observe (data) {
  23. let type = Object.prototype.toString.call(data)
  24. if(type !== '[object Object]' && type !== '[object Array]'){
  25. return
  26. }
  27. if(data.__ob__){
  28. return data.__ob__
  29. }
  30. return new Observer(data)
  31. }
  32. function defineReactive(obj, key, val) {
  33. let childOb = observe(obj[key])
  34. let dep = new Dep()
  35. Object.defineProperty(obj,key,{
  36. enumerable:true,
  37. configurable:true,
  38. get:function reactiveGetter() {
  39. if(Dep.target){
  40. dep.depend()
  41. if(childOb){
  42. childOb.dep.depend()
  43. }
  44. }
  45. return val
  46. },
  47. //...
  48. })
  49. }

实现对数组的处理

基于前面的__ob__来实现对数组的处理的思路:

  1. 因为前面已经说了,使用Object.defineProperty的办法劫持数组,会存在问题。所以在实现数据劫持的时候,数组本身不用管,而是去循环劫持数组的元素,因为元素也有可能是对象。
    实现方法:数组的回调也通过__ob__.dep来收集,在数组调用push,pop等方法时手动去触发__ob__.dep.notify
  2. 原型对象Array.prototype上的方法不能直接修改,因为这样会破坏其他用到这些方法的代码的功能
    实现方法:在数组和Array.prototype的原型链上插入一个自定义的对象,拦截原来的push等方法,在自定义对象中的同名方法中先执行原本的方法,再去人为的调用__ob__.dep.notify()去执行之前收集的回调
  1. //...
  2. class Observer {
  3. constructor(data){
  4. this.dep = new Dep()
  5. if(Array.isArray(data)){
  6. data.__proto__ = arrayMethods
  7. }else {
  8. this.walk(data)
  9. }
  10. //...
  11. }
  12. //...
  13. }
  14. class Dep {
  15. //...
  16. }
  17. class Watcher {
  18. //...
  19. }
  20. const mutationMethods = ['push','pop','shift']
  21. const arrayMethods = Object.create(Array.prototype)
  22. const arrayProto = Array.prototype
  23. mutationMethods.forEach(method => {
  24. arrayMethods[method] = function (...args) {
  25. const result = arrayProto[method].apply(this,args)
  26. this.__ob__.dep.notify()
  27. return result
  28. }
  29. })

注意,数组里面的元素如果是对象,需要变成响应式

  1. //...
  2. class Observer {
  3. constructor(data){
  4. this.dep = new Dep()
  5. if(Array.isArray(data)){
  6. data.__proto__ = arrayMethods
  7. this.observeArray(data)
  8. }else {
  9. this.walk(data)
  10. }
  11. //...
  12. }
  13. //...
  14. observeArray(arr){
  15. for (let i = 0; i < arr.length; i++) {
  16. observe(arr[i])
  17. }
  18. }
  19. }
  20. class Dep {
  21. //...
  22. }
  23. class Watcher {
  24. //...
  25. }
  26. const mutationMethods = ['push','pop','shift']
  27. const arrayMethods = Object.create(Array.prototype)
  28. const arrayProto = Array.prototype
  29. mutationMethods.forEach(method => {
  30. if(method === 'push'){
  31. this.__ob__.observeArray(args)
  32. }
  33. arrayMethods[method] = function (...args) {
  34. const result = arrayProto[method].apply(this,args)
  35. this.__ob__.dep.notify()
  36. return result
  37. }
  38. })

实现computed

根据计算属性几个特点设计思路:

  1. 它的值是一个函数运行的结果
  2. 函数里用到所有属性都会引起计算属性的变化

计算属性仍然属于Vue响应式实现的一种,本质上还是一个watcher。但是又似乎与之前的watcher实现有所不同,因为之前的watcher是只能监听一个属性

解决思路:

Watcher第二参数exp也可以传一个函数,然后运行这个函数并获取返回值,运行过程中,函数里所有的this.xxx属性都会触发setter这样一来,就可以让多个dep都能收集到这个watcher

  1. 计算属性不存在于data选项中,需要单独进行初始化
  2. 计算属性只能取,不能存。也就是说计算属性的setter无效,考虑下面代码:
    1. let vm = new Vue({
    2. data:{
    3. a:3,
    4. b:5
    5. },
    6. watch:{
    7. x() {
    8. console.log('对x的监听回调触发');
    9. }
    10. },
    11. computed:{
    12. x() {
    13. return this.a * this.b
    14. }
    15. }
    16. })

    也意味着,计算属性本身不再需要筐去收集,对一个计算属性进行监听,回调触发的本质是计算属性依赖的其他属性发生了变化。
    初步实现代码如下:

继续解决还存在的问题

  1. 计算属性是惰性的:计算属性依赖的其他属性发生变化时,计算属性不会立即重新计算,要等到对获取计算属性的值,也就是求值时才会重新计算。
  2. 计算属性是缓存的:如果计算属性依赖的其他属性没发生变化,即使重新对计算属性求值,也不会重新计算计算属性。
    考虑如下代码:
    1. let vm = new Vue({
    2. data: {
    3. a: 3,
    4. b: 5,
    5. },
    6. computed: {
    7. x() {
    8. console.log('计算x');
    9. return this.a * this.b
    10. }
    11. }
    12. })
    13. //没有任何打印
    14. vm.x
    15. //15
    16. //计算x
    17. vm.x
    18. //15
    19. vm.a = 4
    20. //没有任何打印
    21. vm.x
    22. //20
    23. //计算x

解决思路:给computed相关的watcher打一个标记this.lazy = true,代表这是一个lazy watcher,当dep通知watcher进行更新时,如果是lazy watcher,则只会给自己一个标记 this.dirty = true等到对计算属性进行求值时,如果watcher的dirty === true则会对watcher进行求值,并且把得到的值保存在watcher实例上(watcher.value),如果watcher的dirty === false则直接返回watcher.value

另外需要注意的一点:

  1. let vm = new Vue({
  2. data:{
  3. a:3,
  4. b:5
  5. },
  6. watch:{
  7. x() { //2号watcher
  8. console.log('监听x');
  9. }
  10. },
  11. computed:{
  12. x() {
  13. console.log('计算x');
  14. return this.a * this.b
  15. }
  16. }
  17. })
  18. //计算x
  19. vm.a = 4
  20. //计算x
  21. //监听x

`

  1. class Vue {
  2. //...
  3. initComputed() {
  4. let computed = this.$options.computed
  5. if(computed){
  6. let keys = Object.keys(computed)
  7. for (let i = 0; i < keys.length; i++) {
  8. const watcher = new Watcher(this, computed[keys[i]],function () {},{lazy:true})
  9. Object.defineProperty(this,keys[i],{
  10. enumerable:true,
  11. configurable:true,
  12. get:function computedGetter() {
  13. if(watcher.dirty){
  14. watcher.get()
  15. watcher.dirty = false
  16. }
  17. return watcher.value
  18. },
  19. set:function computedSetter() {
  20. console.warn('请不要给计算属性赋值')
  21. }
  22. })
  23. }
  24. }
  25. }
  26. }
  27. class Dep {
  28. notify() {
  29. this.subs.forEach((watcher)=>{
  30. //依次执行回调函数
  31. watcher.update()
  32. })
  33. }
  34. }
  35. class Watcher {
  36. constructor(vm,exp,cb,options = {}) {
  37. this.dirty = this.lazy = !!options.lazy
  38. this.vm = vm
  39. this.exp = exp
  40. this.cb = cb
  41. this.id = ++watcherId
  42. this.lazy || this.get()
  43. }
  44. //求值
  45. get() {
  46. Dep.target = this
  47. if(typeof this.exp === 'function'){
  48. this.value = this.exp.call(this.vm)
  49. }else {
  50. this.value = this.vm[this.exp]
  51. }
  52. Dep.target = null
  53. }
  54. update() {
  55. if(this.lazy){
  56. this.dirty = true
  57. }else {
  58. this.run()
  59. }
  60. }
  61. }

目前仍然存在bug,考虑如下测试代码

  1. let vm = new Vue({
  2. data:{
  3. person:{
  4. name:'zs'
  5. }
  6. },
  7. watch:{
  8. x() { //2号watcher
  9. console.log('x监听');
  10. }
  11. },
  12. computed:{
  13. x() { //1号watcher
  14. console.log('x计算');
  15. return JSON.stringify(this.person)
  16. }
  17. }
  18. })
  19. vm.person = {name:'ls'} //没有任何打印

实际Vue中会先后打印 ‘x计算’,’x监听’,我们的实现中仍然没有打印

说明2号watcher执行run的时候,会对x进行求值。因此watcher的run中不光要调用回调,也要调用get()

  1. run() {
  2. //...
  3. Promise.resolve().then(()=>{
  4. this.get()
  5. this.cb.call(this.vm)
  6. let index = watcherQueue.indexOf(this.id)
  7. watcherQueue.splice(index,1)
  8. })
  9. }

但是加上代码后我们的实现中仍然没有打印,问题出在哪里?

展开person对象:

实际Vue中:

3.png

我们的实现中:

4.png

分析原因:

把computed中的watcher称为1号watcher,把watch中的watcher称为2号watcher。在initWatcher调用时,2号watcher上台,求值,触发了persongetter,触发1号watcherget()方法,1号watcher也上台,覆盖了2号watcher,person的筐开始收集台上的1号watcher,结束后清空舞台。person并没有收集到1号watcher

解决思路:

  • 维护一个,有新的watcher上台时入栈,下台时出栈,台上永远是栈顶的watcher
  • watcher被dep收集时,也收集dep,互相收集。这样的话,计算属性的getter完成后,检查舞台上还有没有watcher,有就把自己的watcher收集的dep拿出来通知,收集舞台上的watcher
  1. class Vue {
  2. //...
  3. initComputed() {
  4. //...
  5. Object.defineProperty(this,keys[i],{
  6. enumerable:true,
  7. configurable:true,
  8. get:function computedGetter() {
  9. if(watcher.dirty){
  10. watcher.get()
  11. watcher.dirty = false
  12. }
  13. if(Dep.target){
  14. watcher.deps.forEach(dep => {
  15. dep.depend()
  16. })
  17. }
  18. return watcher.value
  19. },
  20. set:function computedSetter() {
  21. console.warn('请不要给计算属性赋值')
  22. }
  23. })
  24. }
  25. //...
  26. }
  27. //...
  28. }
  29. class Dep {
  30. constructor() {
  31. this.subs = []
  32. }
  33. addSub(watcher) {
  34. this.subs.push(watcher)
  35. }
  36. depend() {
  37. if(Dep.target){
  38. Dep.target.addDep(this)
  39. }
  40. }
  41. notify() {
  42. this.subs.forEach((watcher)=>{
  43. //依次执行回调函数
  44. watcher.update()
  45. })
  46. }
  47. }
  48. let targetStack = []
  49. class Watcher {
  50. constructor(vm,exp,cb,options = {}) {
  51. this.dirty = this.lazy = !!options.lazy
  52. this.vm = vm
  53. this.exp = exp
  54. this.cb = cb
  55. this.id = ++watcherId
  56. this.deps = []
  57. if(!this.lazy){
  58. this.get()
  59. }
  60. }
  61. addDep(dep){
  62. if(this.deps.indexOf(dep) !== -1) {
  63. return
  64. }
  65. this.deps.push(dep)
  66. dep.addSub(this)
  67. }
  68. //求值
  69. get() {
  70. Dep.target = this
  71. targetStack.push(this)
  72. if(typeof this.exp === 'function'){
  73. this.value = this.exp.call(this.vm)
  74. }else {
  75. this.value = this.vm[this.exp]
  76. }
  77. targetStack.pop()
  78. Dep.target = targetStack.length ? targetStack[targetStack.length - 1] : null
  79. }
  80. update() {
  81. if(this.lazy){
  82. this.dirty = true
  83. }else {
  84. this.run()
  85. }
  86. }
  87. run() {
  88. if(watcherQueue.indexOf(this.id) !== -1){ //已经存在于队列中
  89. return
  90. }
  91. watcherQueue.push(this.id)
  92. Promise.resolve().then(()=>{
  93. this.get() //重新求值
  94. this.cb.call(this.vm)
  95. let index = watcherQueue.indexOf(this.id)
  96. watcherQueue.splice(index,1)
  97. })
  98. }
  99. }

实现对模板的编译

目前为止,我们已经实现Vue的响应式系统。现在需要对html进行响应。最简单的方法可以创建一个watcher:

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

在Vue中确实也是这么做的,这个watcher被称为render watcher,watcher中的求值函数并没有这么简单。

我们的实现存在一些问题:

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

为了尽量减少不必要的dom操作和实现跨平台的特性,Vue中引入了Virtual-DOM(虚拟DOM,以下简称VDOM)。

什么是VDOM?简单的说就是一个JS对象,它可以用来描述当前DOM长什么样。

为了得到当前Vue实例的VDOM,每个实例需要有一个函数来生成VDOM,被称为渲染函数。(vm.$options.render)

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

解析器(parser)

关于 Vue 编译原理这块的整体逻辑主要分三步,这三步是有前后关系的:

  • 第一步是将 模板字符串 转换成 element ASTs(解析器)
  • 第二步是对 AST 进行静态节点标记,主要用来做虚拟DOM的渲染优化(优化器)(本课程忽略)
  • 第三步是 使用 element ASTs 生成 render 函数代码字符串(代码生成器)

5.png

AST是什么?

简单的说一种代码转换成另外一种代码时,对源代码的描述。

JS AST在线生成

Vue中对模板parse后的AST长什么样?

  1. {
  2. children: [{…}],
  3. parent: {},
  4. tag: "div",
  5. type: 1, //1-元素节点 2-带变量的文本节点 3-纯文本节点,
  6. expression:'_s(name)', //type如果是2,则返回_s(变量)
  7. text:'{{name}}' //文本节点编译前的字符串
  8. }

源代码生成AST一般包含两个步骤:词法分析和语法分析

6.png

Vue中的parse是每解析到一个token会立即对token进行处理

本课程只以最单纯的html模板为例。v-modelv-if,v-for,@click,以及html中的单标签元素注释等情况可以作为课后作业完成。

思路:

<为标识符,代表一个开始标签或者是结束标签,如果是开始标签,代表树的层级加了一层,如果是结束标签代表层级回退了一层。同时每一层要记录它的父级元素是谁。

所以可以使用一个去维护当前元素到了哪一层。有开始标签则入栈,结束标签则出栈。另外,标签之间是文本节点,文本节点不对栈进行操作

实现对HTML进行parse

  1. function parser(html) {
  2. let stack = []
  3. let root
  4. let currentParent
  5. while (html) {
  6. let ltIndex = html.indexOf('<')
  7. if(ltIndex > 0){ //前面有文本
  8. //type 1-元素节点 2-带变量的文本节点 3-纯文本节点
  9. let text = html.slice(0,ltIndex)
  10. currentParent.children.push(element)
  11. const element = {
  12. type: 3,
  13. text,
  14. parent:currentParent
  15. }
  16. html = html.slice(ltIndex)
  17. }else if(html[ltIndex + 1] !== '/'){ //前面没有文本,且是开始标签
  18. let gtIndex = html.indexOf('>')
  19. const element = {
  20. type: 1,
  21. tag:html.slice(ltIndex + 1,gtIndex), //不考虑dom的任何属性
  22. parent: currentParent,
  23. children:[],
  24. }
  25. if(!root){
  26. root = element
  27. }else {
  28. currentParent.children.push(element)
  29. }
  30. stack.push(element)
  31. currentParent = element
  32. html = html.slice(gtIndex + 1)
  33. }else { //结束标签
  34. let gtIndex = html.indexOf('>')
  35. stack.pop()
  36. currentParent = stack[stack.length - 1]
  37. html = html.slice(gtIndex + 1)
  38. }
  39. }
  40. return root
  41. }

实现对文本节点的parse

思路:

{{}}为标识符,对把插值变量名转换成_s(name)的形式。

  1. function parseText(text) {
  2. let originText = text
  3. let tokens = []
  4. let type = 3
  5. while (text) {
  6. let start = text.indexOf('{{')
  7. let end = text.indexOf('}}')
  8. if(start !== -1 && end !== -1){
  9. type = 2
  10. if(start > 0){
  11. tokens.push(JSON.stringify(text.slice(0,start)))
  12. }
  13. let exp = text.slice(start+2,end)
  14. tokens.push(`_s(${exp})`)
  15. text = text.slice(end + 2)
  16. }else {
  17. tokens.push(JSON.stringify(text))
  18. text = ''
  19. }
  20. }
  21. let element = {
  22. type,
  23. text:originText,
  24. }
  25. type === 2 ? element.expression = tokens.join('+') : ''
  26. return element
  27. }

代码生成器(codegenerator)

生成AST后需要把AST再转换成渲染函数

思路:

  1. 递归AST,遇到元素节点则生成如下格式的字符串_c(标签名, 属性对象, 后代数组)
  2. 遇到文本节点,如果是纯文本节点,则生成如下格式的字符串_v(文本字符串)
  3. 如果是带变量的文本节点,则生成如下格式的字符串_v(_s(变量名))
  4. 为了让变量能正常取到,生成最后将字符串包一层with(this)
  5. 最后把字符串作为函数体生成一个函数,挂载到vm.$options
  1. function generate(ast) {
  2. const code = genElement(ast)
  3. return {
  4. render: `with(this){return ${code}}`,
  5. }
  6. }
  7. function genElement (el){
  8. const children = genChildren(el)
  9. let code = `_c('${el.tag}',{},${children})`
  10. return code
  11. }
  12. function genChildren (el){
  13. if (el.children.length) {
  14. return '[' + el.children.map(child=>genNode(child)).join(',') + ']'
  15. }
  16. }
  17. function genNode (node) {
  18. if (node.type === 1) {
  19. return genElement(node)
  20. } else {
  21. return genText(node)
  22. }
  23. }
  24. function genText (text){
  25. return `_v(${text.type === 2 ? text.expression : JSON.stringify(text.text)})`
  26. }

实现Vdom

什么是VDOM?

简单的说就是一个JS对象,它可以用来描述当前DOM长什么样。

例如:

  1. <ul>
  2. <li>1</li>
  3. <li>2</li>
  4. </ul>

对应的vdom:

  1. {
  2. tag: 'ul',
  3. attrs: {},
  4. children: [
  5. {
  6. tag: 'li',
  7. attrs: {},
  8. children: [
  9. {
  10. tag: null,
  11. attrs: {},
  12. children:[],
  13. text:'1'
  14. }
  15. ]
  16. },
  17. {
  18. tag: 'li',
  19. attrs: {},
  20. children: [
  21. {
  22. tag: null,
  23. attrs: {},
  24. children:[],
  25. text:'2'
  26. }
  27. ]
  28. }
  29. ]
  30. }

VDOM有什么用?

1.大多数情况下,提供了比暴力刷新整个dom树更好的性能

操作JS对象是很快的,但是操作dom元素是很慢的。如果数据发生改变,视图应该怎样更新?

直接重新拼接html模板,然后把新的html挂载在根元素上?

显然这种方法会很耗费性能,因为它要大量的删除和创建dom

如果视图通过vdom来描述,那么当数据发生改变时,可以将新的vdom和旧的vdom进行对比,找到哪里发生了改变,再去对应的dom上改变相应的元素

7.png

  1. 上述步骤只有最后一步更新需要依赖dom api,意味着只要能跑js的地方就可以用vdom去描述当前视图,更新时只用调用相应平台的api就行,实现了跨平台

由渲染函数生成Vdom

定义一个简单的类VNode,描述一个DOM节点的相关信息,实现上一节渲染函数中未实现的_c,_v,_s函数。

  1. //vnode.js
  2. class VNode {
  3. constructor(tag, attrs, children,text) {
  4. this.tag = tag
  5. this.attrs = attrs
  6. this.children = children
  7. this.text = text
  8. }
  9. }
  10. //index.js
  11. class Vue {
  12. //...
  13. _c(tag,attrs,children) {
  14. return new VNode(tag,attrs,children)
  15. }
  16. _v(text) {
  17. return new VNode(null,null,null,text)
  18. }
  19. _s(val){
  20. if(val === null || val === undefined){
  21. return ''
  22. }else if(typeof val === 'object'){
  23. return JSON.stringify(val)
  24. }else {
  25. return String(val)
  26. }
  27. }
  28. }

目前为止,成功地用一个JS树状对象,描述了渲染后的HTML应该长什么样。运行vm.$options.render.call(vm)即可得到当前vdom

实现diff和patch

首先实现一个createElm函数将Vdom转化为真正的dom,稍后更新dom会用到此函数

  1. function createElm(vnode) {
  2. if(!vnode.tag){
  3. const el = document.createTextNode(vnode.text)
  4. vnode.elm = el
  5. return el
  6. }
  7. const el = document.createElement(vnode.tag)
  8. vnode.elm = el
  9. vnode.children.map(createElm).forEach(childDom => {
  10. el.appendChild(childDom)
  11. })
  12. return el
  13. }

延续模板编译里的思路,将原先粗暴式的代码进行改造。

思路:

  • 实现一个$mount函数,初次挂载到真实dom时调用,将原先的初始化render watcher的逻辑搬到$mount里
  • 实现一个_update函数,该函数接受一个新的vdom,然后对比新旧vdom并更新真实dom,render watcher中不再暴力更新dom,而是调用_update函数
  1. //改造前
  2. new Watcher(this, ()=>{
  3. document.querySelector('#app').innerHTML = `<p>${this.name}</p>`
  4. },()=>{})
  5. //改造后
  6. class Vue {
  7. constructor(options) {
  8. //...
  9. if(options.el){
  10. let html = document.querySelector(options.el).outerHTML
  11. let ast = parser(html)
  12. let code = generate(ast).render
  13. this.$options.render = new Function(code)
  14. this.$mount(options.el)
  15. }
  16. }
  17. $mount(el) {
  18. this.$el = document.querySelector(el)
  19. this._watcher = new Watcher(this, ()=>{this._update(this.$options.render.call(this))}, ()=>{})
  20. }
  21. _update(vnode) {
  22. if(this._vnode){
  23. patch(this._vnode,vnode)
  24. }else {
  25. patch(this.$el,vnode)
  26. }
  27. this._vnode = vnode
  28. }
  29. }

接下来,实现vdom机制中最核心的patch。vue中vdom进行patch的逻辑是基于snabbdom,课后同学们可以进一步阅读源码,本课程不考虑节点属性和节点的key。

思路:

  • patch函数接受两个参数:旧的vdom和新的vdom
  • 当第一次挂载时旧的vdom是一个真实dom,单独处理
  • 后续更新时,分为如下三种情况
    1. 新节点不存在,则删除对应的dom
    2. 新旧节点标签不一样或文本不一样,则调用createElm生成新dom,并替换旧dom
    3. 旧节点不存在,新节点存在,则调用createElm生成新dom,并原dom后添加新dom
    4. 递归以上逻辑
  1. function patch(oldNode,newNode,) {
  2. const isRealElement = oldNode.nodeType
  3. //如果是对真实dom进行patch
  4. if(isRealElement){
  5. let parent = oldNode.parentNode
  6. parent.replaceChild(createElm(newNode),oldNode)
  7. return
  8. }
  9. //当前vdom对应的真实dom
  10. let el = oldNode.elm
  11. //当前vdom对应的真实父级dom
  12. let parent = el.parentNode
  13. if(newNode){
  14. newNode.elm = el
  15. }
  16. if (!newNode) { //新节点不存在,删除
  17. parent.removeChild(el)
  18. } else if (changed(newNode, oldNode)) { //发生了变化,替换
  19. parent.replaceChild(createElm(newNode), el)
  20. } else if(newNode.children){
  21. const newLength = newNode.children.length
  22. const oldLength = oldNode.children.length
  23. for (let i = 0; i < newLength || i < oldLength; i++) {
  24. if (i >= oldLength) { //旧节点不存在,有多余的新节点,增加
  25. el.appendChild(createElm(newNode.children[i]))
  26. } else { //递归
  27. patch(oldNode.children[i], newNode.children[i])
  28. }
  29. }
  30. }
  31. }
  32. //由vdom创建真实dom
  33. function createElm(vnode) {
  34. if(!vnode.tag){
  35. const el = document.createTextNode(vnode.text)
  36. vnode.elm = el
  37. return el
  38. }
  39. const el = document.createElement(vnode.tag)
  40. vnode.elm = el
  41. vnode.children.map(createElm).forEach(childDom => {
  42. el.appendChild(childDom)
  43. })
  44. return el
  45. }
  46. //判断是否是相同节点
  47. function changed(newNode, oldNode) {
  48. return (newNode.tag !== oldNode.tag || newNode.text !== oldNode.text)
  49. }