网络日志

FinClip 前端: VUE 核心原理归纳

目前,几乎所有的小程序框架都是支持前端JavaScript语言和 vue.js 框架的。FinClip 小程序是兼容各家平台的,因此在学习了框架使用之后的进阶就要熟悉框架的底层原理。

1、数据响应式

  1. 首先判断数据的类型,如果是基础数据类型,直接返回,如果已经有 ob 属性,表示已经是响应式的数据了,直接返回该数据。如果是对象就走第2步,如果是数组就走第3步
  2. 对象是通过 Object.defineProperty,在 getter 里收集依赖,在 setter 里触发更新
  3. 数组是首先拷贝数组的原型,然后基于拷贝的原型改写(push,pop,unshift,shift,sort,reverse,splice)七个可以改变数组长度的方法,然后将改写后的原型赋给数组的隐式原型
  4. 对数组的隐式原型赋值后,还要观测数组的每一项,重复第一步
  5. 如果 Object.defineProperty 的 setter 里赋值,如果新赋的值是对象,也要进行观测
  6. 如果对数组的操作是有数据新增(push,unshift,splice),还需要观测数组新增的每一项,同第4步(这里Vue源码的实现是给每个响应式数据[对象和数组]新增了一个不可枚举的属性 ob,它的作用有三,其一是用来判断数据是否已经是响应式的数据,如果是就不需再次观测,其二是属性 ob 是 Observer 类的一个实例,实例上有对数组每一项进行响应式处理的方法),其三是 $set 方法中,ob 用来判断要设置属性的对象是不是响应式的对象,如果它本身就不是响应式对象,则该属性无需定义为响应式的属性对象是在 Object.defineProperty 的 getter 里进行依赖的收集,在 setter 里触发更新。具体是通过观察者模式,每一个属性都有一个 Dep 类的实例,Dep.target 有值即指向 watcher 的时候,在 dep 内收集 watcher,并且在 watcher 内收集 dep,dep 和 watcher 是多对多的关系,因为一个组件会有多个属性,而 watcher 是组件级的,所以 一个 watcher 可能对应多个 dep ,dep 可能对应多个组件,组件内部的 computed 和 watch 都是 watcher。 不管是根组件还是非根组件(函数),它们的 data 最终的值都是对象,所以只会在 data 最外层对象的某些属性值是数组,所以在 Object.defineProperty 的 getter 里对数组进行依赖收集,我们知道依赖的收集是调用 dep 类上收集依赖的方法,Vue 的做法是在创建 Observer 类的实例的时候,定义了一个属性 dep,dep 是 Dep 类的实例。对于多维数组和数组新增的数据,Vue 的做法是,在创建 Observer 类的实例的时候,设置了一个不可枚举的属性 ob ,它的值是 Observer 类的实例,所以我们在对多维数组进行依赖收集的时候,可以调用 ob 的 dep 的方法,对于数组新增的数据,调用 ob 上的方法对数组的每一项做数据响应式,并且调用 ob.dep 上的 notify 方法触发更新。

1.1、数据初始化的顺序:props -> methods -> data -> computed -> watch

  • 如果 data 的层级过深会影响性能
  • 对象有新增和删除属性没办法做数据的响应式处理(通过$set解决)
  • 如果给对象的属性赋值为对象,也会对赋值后的对象进行响应式处理

1.2、data 中数组的响应式处理是通过改写数组原型上的七个方法(push/pop/shift/unshift/sort/reverse/splice)

  • 在重写数组原型之前,Vue 给每个响应式数据新增了一个不可枚举的 ob 属性,这个属性指向了 Observer 实例,可以用来防止已经被响应式处理的数据反复被响应式处理,其次,响应式的数据可以通过 ob 获取到 Observer 实例的相关方法
  • 对于数组的新增操作(push/unshift/splice),会对新增的数据也做响应式处理
  • 通过索引修改数组内容和直接修改数组长度是观测不到的

2、Vue如何进行依赖收集的?

  • 每个属性都有 dep 实例,dep 实例用来收集它所依赖的 watcher
  • 在模板编译的时候,会取值触发依赖的收集
  • 当属性发生变化时会触发 watcher 更新

3、Vue的更新粒度是组件级?

  • 首先渲染 watcher 是组件级的。在初始化的时候,会调用 _init 方法,_init 内部会调用 $mount 方法,$mount 方法会调用 mountComponent 方法,mountComponent 方法内部定义了 updateComponent 方法,updateComponent 方法内部就是调用 _update 方法将 vnode 渲染成真实 DOM,mountComponent 方法会 new 一个渲染 watcher,并把 updateComponent 传给渲染 watcher ,所以渲染 watcher 可以重新渲染DOM(试想一下,如果我们没有把更新DOM渲染的方法传递给 watcher ,更改数据后,我们需要手动去调用DOM渲染的方法;传递给 watcher 后,数据变化后,可以让 watcher 自动的去调用更新DOM渲染的方法)
  • 在 render 函数生成 vnode 时,会判断是否是原生的HTML标签,如果不是原生HEML标签即是 组件,会创建组件的 vnode,子组件本质是 VueComponent 函数,VueComponent 内部会调用 _init 方法,所以创建子组件 vnode 的时候,也会 new 一个渲染 watcher,所以说渲染 watcher 是组件级的,也就是说 Vue 的更新粒度是组件级的

4、模板编译原理

  • 注意一:我们平时开发中使用的是不带编译的 Vue 版本(runtime-only),所以在传入选项的时候是不能使用 template 的
  • 注意二:我们 .vue 文件中的 template 是经过 vue-loader 处理的,vue-loader 其实也是使用 vue-template-compiler 处理
  1. 如果选项 options 里有 render 直接使用 render,如果没有 render 看选项里有没有 tempalte,如果有就用 template,如果没有就看选项里有没有 el,如果有 template = document.querySelector(el),最后用 compileToFunctions(tempalte) 生成render最终都是生成 render 函数,优先级是 render > tempalte > el
  2. 模板编译的整体逻辑主要分为三个部分:第一步:将模板字符串转换成 element ASTs (解析器)第二步:对 AST 进行静态节点标记,主要用来做虚拟 DOM 的渲染优化 (优化器)(进行新旧vnode对比的时候可以跳过静态节点)第三步:使用 elements ASTs 生成 render 函数代码字符串 (代码生成器)

4.1、生成 AST 的过程

  • 其实就是 while 循环里不断的通过正则匹配字符串,如果是匹配到是开始标签,就触发 start 钩子处理开始标签和属性,如果匹配到文本,就触发 chars 钩子处理文本,如果匹配到结束标签,就调用 end 钩子处理结束标签。处理完后就把模板中已经匹配到子串截取出来,一直这样循环操作,直到模板的字符串被截取成空串跳出 while 循环。
  • 在匹配到开始标签后,就把开始标签压入栈中,匹配到结束标签就把栈顶元素出栈。第一个进栈的元素就是根节点,除了第一根元素外,其他元素在进栈之前,栈顶的元素就是该元素的父亲节点,所以可以维护元素之间的父子关系(入栈元素的parent是栈顶元素,该入栈元素是栈顶元素的儿子),当栈被清空之后,根节点就是生成的 AST 匹配到文本内容是没有子节点的,所以它直接作为栈顶元素的儿子即可。

    4.2、解析器运行过程

  • AST 是用 JS 中的对象来描述节点,一个对象代表一个节点,对象的属性用来保存节点所需的各种数据。
  • 解析器内部分了好几个子解析器,比如 HTML解析器,文本解析器,过滤器解析器。其中最主要的是 HTML解析器,HTML解析器的作用就是解析HTML,它在解析的过程中会不断的触发各种钩子函数。这些钩子函数包括,开始标签钩子函数(start)、结束标签钩子函数(end),文本钩子函数(chars)和注释钩子函数(comment)。
  • 实际上,模板解析的过程就是不断的调用钩子函数的过程,读取 template,使用不同的正则表达式匹配到不同的内容,然后触发对应的钩子函数处理匹配到的字符串截取片段。比如比配到开始标签,触发 start 钩子函数,start 钩子函数处理匹配到开始标签片段,生成一个标签节点添加到抽象语法树上。
  • HTML解析器解析HTML的过程就是循环(while循环)的过程,简单来说就是利用 HTML 模板字符串来循环,每轮循环都从 HTML字符串中截取一小段字符串,重复以上过程,一直到 HTML字符串被截取成一个空串结束循环,解析完毕。
  • 在解析开始标签和结束标签是用栈来维护的,解析到开始标签就压入栈中,解析到结束标签,就从栈顶取出对应的开始标签的AST,栈顶的前一个开始标签就是该标签的父元素,然后就可以建立父子元素之间的关系。
  • 文本解析器是对 HTML 解析器解析出来的文本进行二次加工。文本分为两种类型,一种是纯文本,一种是带变量的文本。HTML解析器在解析文本的时候,并不会区分是纯文本还是带变量的文本,如果是纯文本,不需要进行任何处理,带变量的文本需要文本解析器的进一步解析,因为带变量的文本在使用虚拟DOM进行渲染时,需要将变量替换成变量中的值。
  • 文本解析器通过正则匹配出变量,把变量改写成 _s(x)的形式添加到数组中

4.3、初始渲染原理

  1. 首先是生成 render 函数
  2. vm._render 函数生成虚拟DOM render 函数主要返回了这样的代码 _c('div'{id:"app"},_c('div',undefined,_v("hello"+_s(name)),_c('span',undefined,_v("world")))),所以需要定义 _c, _v, _s 这样的函数才能真正转换成虚拟DOM
  3. vm._update 方法将生成的虚拟DOM进行实例挂载 update 方法的核心是利用 patch 方法来渲染和更新视图,这里是初次渲染,patch 方法的第一个参数是真实DOM,更新阶段第一个参数是 oldVnode

5、Vue.mixin 的使用场景和原理

  • Vue.mixin的作用就是抽离公共的业务逻辑,原理类似 “对象的继承”,当组件初始化的时候会调用 mergeOptions 方法进行合并,对于不同的 key(data,hooks,components...)有不同的合并策略。如果混入的数据和组件本身的数据有冲突,会采用“就近原则”,以组件本身的为准。
  • mixin有很多的缺陷:命名冲突,来源不清晰,依赖问题

6、nextTick 在哪里使用?原理是什么?

  • nextTick 可用于获取更新后的 DOM
  • Vue的数据更新是异步的,会把所有的数据更新操作都放入任务队列中,然后在 nextTick 中去依次执行这些任务,nextTick 是一个异步任务,采用的是优雅降级(Promise -> MutationObserver -> setImmediate -> setTimeout)

7、watch 原理

  1. watch 的使用方式,可以是对象,可以是函数,也可以是数组
  2. 不论是哪种使用方式,watch 的每一个属性对应的函数(数组的使用方式,数组中的每一项(函数))都是一个 用户watcher,其实现都是调用的 $watch(vm, handler)
  3. $watch 方法的实现都是 new Watcher(),只不过是 options 参数里标记了是用户自定义的 watcher(options.user = true)
  4. watch 的属性对应的函数里有新值和旧值,我们是如何返回新值和旧值的呢?
  5. new Watcher() 的时候传递的是属性的 key,我们要把它包装成一个函数(函数内部就是根据 key 取值),赋值给 Watcher类的 getter 属性,在 Watcher 类实例化的时候,会调用一次 get 方法,我们就可以拿到它的值(取值同时会进行依赖收集)
  6. 在值更新后,会再次调用 Watcher 类的 get 方法获得新值
  7. 然后判断 watcher 的类型,如果是用户 watcher ,执行 callback,把新值旧值传递给 callback
  • watch api 不管是哪种使用方式,最终都是一个 key, 一个函数,对应一个 user watcher ,每一个 watcher 都有一个 getter 方法,watch api 对应的 getter 方法是根据 key 来封装的,getter 方法就是取 key 对应的数据,因为 watcher 在初始化的时候默认会调用一次 getter ,所以就拿到 key 对应的旧值了,取值也就进行了依赖收集,当 key 对应的数据改变了,watcher 的 getter 方法会再次执行,这时就拿到了新值,然后调用 key 对应的回调函数,将新值和旧值传给它

8、computed 原理

  1. 每个计算属性本质上也是一个用户 watcher,在它取值的时候进行依赖收集,computed 依赖的值改变后触发更新
  2. 计算属性的 watcher 在初始化的时候会有两个属性 lazy 和 dirty
  3. watcher 在初始化的时候,会默认调用一次 get 方法,但是 computed 默认是不执行的,所以用 lazy 属性来标记是 computed watcher
  4. computed 是有缓存的,即依赖的值没有发生改变,多次获取,是不会多次调用 watcher 的 get 方法获取值的,所以用 dirty 属性来标记是否需要重新计算值,如果不需要计算,直接返回 watcher 的 value,如果需要计算,再来调用 get 方法获取新的值,再返回 watcher 的 value

补充:什么时候 dirty 的值是 true 呢?

  • computed watcher 初始化的时候
  • computed watcher 依赖的值改变时(调用了 computed watcher 的 update 方法,即可表示依赖的值改变了)

9、diff算法

  1. Vue的diff算法是平级比较,不考虑跨级比较的情况。内部采用深度递归和双指针的方式
  2. 首先比对是否是相同的节点,如果不是删除旧的DOM,生成新的DOM插入
  3. 如果是相同的节点,比对更新属性
  4. 判断是否是文本节点,如果是,判断文本内容是否相同,不同更新文本内容
  5. 比对新旧子节点,如果只有新的有子节点,新增子节点插入;如果只有旧的有子节点,将元素的 innerHTML 置为空
  6. 如果新旧都有子节点,比对新旧子节点(采用双指针)
  7. 依次是头头、尾尾、头尾、尾头比较,没有匹配到,就乱序比对
  8. 乱序比对:建立旧的节点的映射表(key->index)
  9. 新的起始节点是否能在旧的映射表中找到,不能找到直接在旧的前面插入,如果找到,将映射表找到的旧的节点,移动到前面,并将该位置置为null
  10. 因为在乱序比对中,有将旧节点置为 null 的情况,所以在进行子节点比对前,先判断该节点是否为null,为null顺移
  11. 比对完之后如果新的节点还有,插入新的节点(插入的位置要判断是否在哪里插入),如果旧的节点还有,删除旧的节点(null的位置跳过)

学习框架技术是为了更好的开发,学习底层原理是为了让产品更好用,更好的让finclip小程序在各家平台上更好的兼容和流畅使用。


如果您有对小程序的设计,研发与使用的相关想法,都欢迎登录 FinClip.com 查找答案,如果在使用过程中遇到问题,也欢迎与我们联系。