vlambda博客
学习文章列表

前端框架核心学习(一)

框架在现代的前端开发中占据举足轻重的地位。

目前流行的前端框架:Angular、React 和 Vue,它们各有特点和受众,都值得开发者认真思考和学习。

那么我们在精力有限的情况下,如何做到「触类旁通」、如何提取框架共性、提高学习和应用效率呢?

我们将用三篇文章来带大家一块进行前端框架核心的学习。

相关核心知识:

核心关键词:

  • 双向绑定
  • 依赖收集
  • 发布订阅模式
  • MVVM/MVC
  • 虚拟DOM
  • 虚拟DOM diff
  • 模版编译

响应式框架的基本原理

关于响应式原理和双向绑定的基本概念,网上有很多。我们从直观的行为上来看:

当页面中数据发生变化时,不用在代码层面重新开发相关逻辑去更新视图变化,视图会自己更新。

这一过程中,需要了解下几个点:

  • 视图变化依赖哪些数据 => 依赖收集
  • 如何感知到数据变化 => 数据劫持 / 数据代理
  • 数据变化时,视图是怎么知道,并且更新的 => 发布订阅模式

数据劫持以及代理

感知数据变的方式很简单:数据劫持或者数据代理。通常的方式是通过 Object.defineProperty 实现,其定义了两个方法:getter 和 setter。

let data = {
   stage'GitChat',
   course: {
     title'前端面试宝典',
     author'小助手',
     publishTime'2022/04/20'
   }
 }

 Object.keys(data).forEach(key => {
   let currentValue = data[key]

   Object.defineProperty(data, key, {
     enumerabletrue,
     configurablefalse,
     get() {
       console.log(`getting ${key} value now, getting value is:`, currentValue)
       return currentValue
     },
     set(newValue) {
       currentValue = newValue
       console.log(`setting ${key} value now, setting value is`, currentValue)
     }
   })
 })
data.course

//  getting course value now, getting value is:
//  {title: "前端面试宝典", author: "小助手", publishTime: "2022/04/20"}

data.course = '前端面试宝典 2'
// setting course value now, setting value is 前端面试宝典 2

但是这种实现有一个问题,例如:

data.course.title = '前端面试宝典 2'

// getting course value now, getting value is: 
// {title: "前端面试宝典", author: "小助手", publishTime: "2022/04/20"}

只会有 getting course value now, getting value is: {title: "前端面试宝典", author: "小助手", publishTime: "2022/04/20" } 的输出,这是因为我们尝试读取了 data.course 信息。但是修改 data.course.title 的信息并没有打印出来。

出现这个问题的原因是因为我们的实现代码只进行了一层 Object.defineProperty,或者说只对 data 的第一层属性进行了 Object.defineProperty,对于嵌套的引用类型数据结构:data.course,我们同样应该进行拦截。

为了达到深层拦截的目的,将 Object.defineProperty 的逻辑抽象为 observe 函数,并改用递归实现:

let data = {
   stage'GitChat',
   course: {
     title'前端面试宝典',
     author'小助手',
     publishTime'2022/04/20'
   }
 }

 const observe = data => {
   if (!data || typeof data !== 'object') {
       return
   }
   Object.keys(data).forEach(key => {
     let currentValue = data[key]

     observe(currentValue)

     Object.defineProperty(data, key, {
       enumerabletrue,
       configurablefalse,
       get() {
         console.log(`getting ${key} value now, getting value is:`, currentValue)
         return currentValue
       },
       set(newValue) {
         currentValue = newValue
         console.log(`setting ${key} value now, setting value is`, currentValue)
       }
     })
   })
 }

 observe(data)

这样一来,就实现了深层数据拦截:

data.course.title = '前端面试宝典 2'

// getting course value now, getting value is: {// ...} // setting title value now, setting value is 前端面试宝典 2

请注意,我们在 set 代理中,并没有对 newValue 再次递归进行

observe(newValue)。也就是说,如果赋值是一个引用类型:

data.course.title = {
     title'前端面试宝典 2'
 }

无法实现对 data.course.title 数据的观察。这里为了简化学习成本,默认修改的数值符合语义,都是基本类型。

在尝试对 data.course.title 赋值时,首先会读取 data.course,因此输出:getting course value now, getting value is: {// ...},赋值后,触发 data.course.title 的 setter,输出:setting title value now, setting value is 前端面试宝典 2。

因此我们总结出:对数据进行拦截并不复杂,这也是很多框架实现的第一步。

监听数组变化

如果上述数据中某一项变为数组

let data = {
   stage'GitChat',
   course: {
     title'前端面试宝典',
     author: ['小助手''小助手2'],
     publishTime'2022/04/20'
   }
 }

 const observe = data => {
   if (!data || typeof data !== 'object') {
       return
   }
   Object.keys(data).forEach(key => {
     let currentValue = data[key]

     observe(currentValue)

     Object.defineProperty(data, key, {
       enumerabletrue,
       configurablefalse,
       get() {
         console.log(`getting ${key} value now, getting value is:`, currentValue)
         return currentValue
       },
       set(newValue) {
         currentValue = newValue
         console.log(`setting ${key} value now, setting value is`, currentValue)
       }
     })
   })
 }

 observe(data)


 data.course.author.push('小助手3')
 // getting course value now, getting value is: {//...}
 // getting author value now, getting value is: (2) [(...), (...)]

我们只监听到了 data.course 以及 data.course.author 的读取,而数组 push 行为并没有被拦截。这是因为 Array.prototype 上挂载的方法并不能触发 data.course.author 属性值的 setter,由于这并不属于做赋值操作,而是 push API 调用操作。然而对于框架实现来说,这显然是不满足要求的,当数组变化时我们应该也有所感知。

Vue 同样存在这样的问题,它的解决方法是:将数组的常用方法进行重写,进而覆盖掉原生的数组方法,重写之后的数组方法需要能够被拦截。

实现逻辑如下:

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

 arrMethods.forEach(method => {
   const oldMethod = Array.prototype[method]
   const newMethod = function(...args{
     oldMethod.apply(this, args)
     console.log(`${method} 方法被执行了`)
   }
   arrExtend[method] = newMethod
 })

示例代码中加入了一行 console.log。使用时:

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

 arrMethods.forEach(method => {
   const oldMethod = Array.prototype[method]
   const newMethod = function(...args{
     oldMethod.apply(this, args)
     console.log(`${method} 方法被执行了`)
   }
   arrExtend[method] = newMethod
 })

 Array.prototype = Object.assign(Array.prototype, arrExtend)


 let data = {
   stage'GitChat',
   course: {
     title'前端开发进阶',
     author: ['Lucas''Ronaldo'],
     publishTime'2018 年 5 月'
   }
 }

 const observe = data => {
   if (!data || typeof data !== 'object') {
       return
   }
   Object.keys(data).forEach(key => {
     let currentValue = data[key]

     observe(currentValue)

     Object.defineProperty(data, key, {
       enumerabletrue,
       configurablefalse,
       get() {
         console.log(`getting ${key} value now, getting value is:`, currentValue)
         return currentValue
       },
       set(newValue) {
         currentValue = newValue
         console.log(`setting ${key} value now, setting value is`, currentValue)
       }
     })
   })
 }

 observe(data)

 data.course.author.push('Messi')

// getting course value now, getting value is: {//...}
// getting author value now, getting value is: (2) [(...), (...)]
// push 方法被执行了

对应代码:

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

 arrMethods.forEach(method => {
   const oldMethod = Array.prototype[method]
   const newMethod = function(...args{
     oldMethod.apply(this, args)
     console.log(`${method} 方法被执行了`)
   }
   arrExtend[method] = newMethod
 })

 Array.prototype = Object.assign(Array.prototype, arrExtend)


 let data = {
   stage'GitChat',
   course: {
     title'前端面试宝典',
     author: ['小助手1''小助手2'],
     publishTime'2020/04/20'
   }
 }

 const observe = data => {
   if (!data || typeof data !== 'object') {
       return
   }
   Object.keys(data).forEach(key => {
     let currentValue = data[key]

     observe(currentValue)

     Object.defineProperty(data, key, {
       enumerabletrue,
       configurablefalse,
       get() {
         console.log(`getting ${key} value now, getting value is:`, currentValue)
         return currentValue
       },
       set(newValue) {
         currentValue = newValue
         console.log(`setting ${key} value now, setting value is`, currentValue)
       }
     })
   })
 }

 observe(data)

 data.course.author.push('小助手3')

// getting course value now, getting value is: {//...}
// getting author value now, getting value is: (2) [(...), (...)]
// push 方法被执行了

Object.defineProperty VS Proxy

我们首先尝试使用 Proxy 来完成代码重构:

let data = {
  stage'GitChat',
  course: {
     title'前端面试宝典',
     author: ['小助手1''小助手2'],
     publishTime'2020/04/20'
   },
};

const observe = (data) => {
  if (
    !data ||
    Object.prototype.toString.call(data) !== '[object Object]'
  ) {
    return;
  }

  Object.keys(data).forEach((key) => {
    let currentValue = data[key];
    // 事实上 proxy 也可以对函数类型进行代理。
    // 这里只对承载数据类型的 object 进行处理,读者了解即可。
    if (typeof currentValue === 'object') {
      observe(currentValue);
      data[key] = new Proxy(currentValue, {
        set(target, property, value, receiver) {
          // 因为数组的 push 会引起 length 属性的变化,
          // 所以 push 之后会触发两次 set 操作,
          // 我们只需要保留一次即可,property 为 length 时,忽略
          if (property !== 'length') {
            console.log(
              `setting ${key} value now, setting value is`,
              currentValue
            );
          }
          return Reflect.set(target, property, value, receiver);
        },
      });
    } else {
      Object.defineProperty(data, key, {
        enumerabletrue,
        configurablefalse,
        get() {
          console.log(
            `getting ${key} value now, getting value is:`,
            currentValue
          );
          return currentValue;
        },
        set(newValue) {
          currentValue = newValue;
          console.log(
            `setting ${key} value now, setting value is`,
            currentValue
          );
        },
      });
    }
  });
};

observe(data);

已经符合我们的需求了。注意这里在使用 Proxy 进行代理时,并没有对 getter 进行代理,因此上述代码的输出结果并不像之前使用 Object.defineProperty 那样也会有 getting value 输出。

整体实现并不难理解,需要了解最基本的 Proxy 知识。简单总结一下,对于数据键值为基本类型的情况,我们使用 Object.defineProperty;对于键值为对象类型的情况,继续递归调用 observe 方法,并通过 Proxy 返回的新对象对 data[key] 重新赋值,这个新值的 getter 和 setter 已经被添加了代理。

了解了 Proxy 实现之后,我们对 Proxy 实现数据代理和 Object.defineProperty 实现数据拦截进行对比,会发现:

  • Object.defineProperty 不能监听数组的变化,需要进行数组方法的重写
  • Object.defineProperty 必须遍历对象的每个属性,且对于嵌套结构需要深层遍历
  • Proxy 的代理是针对整个对象的,而不是对象的某个属性,因此不同于Object.defineProperty 的必须遍历对象每个属性,Proxy 只需要做一层代理就可以监听同级结构下的所有属性变化,当然对于深层结构,递归还是需要进行的
  • Proxy 支持代理数组的变化
  • Proxy 的第二个参数除了 set 和 get 以外,可以有 13 种拦截方法,比起 Object.defineProperty() 更加强大,这里不再一一列举
  • Proxy 性能将会被底层持续优化,而 Object.defineProperty 已经不再是优化重点

最后

《前端面试题宝典》经过近一年的迭代,现已推出  和 电脑版刷题网站 (https://fe.ecool.fun/),欢迎大家使用~