写点什么

FinClip+ 系列 | VUE 前端开发框架核心原理

作者:Speedoooo
  • 2022 年 5 月 16 日
  • 本文字数:5403 字

    阅读完需:约 18 分钟

FinClip+系列 | VUE前端开发框架核心原理

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


除私有化版本外,FinClip 现已推出 SAAS 版本,无需部署即可使用全部功能,每月有 10000 次免费发布调用,平台自带小程序流量统计,可根据实际用量灵活拓展。帮助企业以最低的价格实现商业化运行。

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 如何进行依赖收集的?

  1. 每个属性都有 dep 实例,dep 实例用来收集它所依赖的 watcher

  2. 在模板编译的时候,会取值触发依赖的收集

  3. 当属性发生变化时会触发 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

  1. 模板编译的整体逻辑主要分为三个部分:第一步:将模板字符串转换成 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 算法

Vue 的 diff 算法是平级比较,不考虑跨级比较的情况。内部采用深度递归和双指针的方式


  1. 首先比对是否是相同的节点,如果不是删除旧的 DOM,生成新的 DOM 插入

  2. 如果是相同的节点,比对更新属性

  3. 判断是否是文本节点,如果是,判断文本内容是否相同,不同更新文本内容

  4. 比对新旧子节点,如果只有新的有子节点,新增子节点插入;如果只有旧的有子节点,将元素的 innerHTML 置为空

  5. 如果新旧都有子节点,比对新旧子节点(采用双指针)

  6. 依次是头头、尾尾、头尾、尾头比较,没有匹配到,就乱序比对

  7. 乱序比对:建立旧的节点的映射表(key->index)

  8. 新的起始节点是否能在旧的映射表中找到,不能找到直接在旧的前面插入,如果找到,将映射表找到的旧的节点,移动到前面,并将该位置置为 null

  9. 因为在乱序比对中,有将旧节点置为 null 的情况,所以在进行子节点比对前,先判断该节点是否为 null,为 null 顺移

  10. 比对完之后如果新的节点还有,插入新的节点(插入的位置要判断是否在哪里插入),如果旧的节点还有,删除旧的节点(null 的位置跳过)


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


本文首发于凡泰极客博客,作者:李丽强

用户头像

Speedoooo

关注

还未添加个人签名 2021.10.08 加入

还未添加个人简介

评论

发布
暂无评论
FinClip+系列 | VUE前端开发框架核心原理_Vue_Speedoooo_InfoQ写作社区