前言
项目组个人分享系列-- 参加公司的技术分享,打算以后每个月分享一次并整理发布,只要能对部分同学有所帮助,就不枉我少睡得那些觉了zzz 时间原因,只写了1.0版,未涉及虚拟dom(用的是文档碎片),下次一定补上2.0VDOM版,其实也挺好,循序渐进才是王道
我是本文目标
- 代码级别解释vue的两大核心概念(数据观测 依赖收集)
- 异步更新及$nextTick原理解析
- 顺带提出Vue开发时的一些小建议
实现MVVM:数据驱动视图(数据劫持)
问题
我们更改数据之后,如何触发渲染视图的逻辑 (顺带解释Vue文档中说的两个缺陷及解决:1. 对象属性的增删无法被监听 2.数组索引改值无法被监听)
目标:观测数据,即当数据改变,要执行某个方法(渲染页面的方法)
核心思路:观察者设计模式 + Object.defineProperty
-
定义核心方法observe:给一个对象,对对象中所有属性的取值赋值进行劫持(赋值执行渲染逻辑,取值待会再说)
/** * 将数据设置为响应式 * @param {*} data */ export function observe(data) { if(typeof data !== 'object' || data == null){ return; } return new Observer(data); }
-
核心类Observer observe方法的本质:递归实现Object.definPro 监听所有data (获得提示:不要层级过深,纯渲染数据不要给vue托管)
此处要对对象和数组区分对待,因为监听数组的key(也就是索引)性能过差
if (Array.isArray(data)) { // 只能拦截数组的方法,但对数组中的每一项 无法监听 需要观测 data.__proto__ = arrayMethods; observerArray(data) } else { this.walk(data) }
-
对象直接监听即可
walk(data) { let keys = Object.keys(data); for (let i = 0; i < keys.length; i++) { let key = keys[i]; let value = data[keys[i]]; // 对define的封装 defineReactive(data, key, value) } }
-
数组的观测分为两部分,数组本身、及数组内元素
- 数组本身的监听采用对七个方法进行装饰者模式重写(注意对新增的val也进行监听)
- 数组内元素的监听直接遍历递归observe即可
import { observe } from "./index.js"; // 拦截用户调用的push shift unshift pop reverse sort splice concat let oldArrayProtoMethods = Array.prototype; export let arrayMethods = Object.create(oldArrayProtoMethods); let methods = ['push','shift','unshift', 'pop', 'reverse', 'sort', 'splice', 'concat']; /** * 循环数组 对数组中的每一项进行观测 * @param {*} inserted */ export function observerArray(inserted) { for (let i = 0; i < inserted.length; i++) { observe(inserted[i]) } } methods.forEach(method=>{ arrayMethods[method] = function (...args){ let r = oldArrayProtoMethods[method].apply(this,args) // todo let inserted; switch (method) { case 'push': case 'unshift': inserted = args;break; case "splice": inserted = args.slice(2) default: break; } if(inserted) observerArray(inserted) console.log('数组更新方法 == 去渲染页面'); return r; } })
-
附送:缺陷的解决: 新增属性无法被观测====$set(target,key,val)
判断target 类型
- 为数组 splice
- 为对象 defineReactive (其实就是Object.definePro)
export function set (target: Array<any> | Object, key: any, val: any): any {
// 1.是开发环境 target 没定义或者是基础类型则报错
if (process.env.NODE_ENV !== 'production' &&
(isUndef(target) || isPrimitive(target))
) {
warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
}
// 2.如果是数组 Vue.set(array,1,100); 调用我们重写的splice方法 (这样可以更新视图)
if (Array.isArray(target) && isValidArrayIndex(key)) {
target.length = Math.max(target.length, key)
target.splice(key, 1, val)
return val
}
const ob = (target: any).__ob__
// 4.如果是Vue实例 或 根数据data时 报错
if (target._isVue || (ob && ob.vmCount)) {
process.env.NODE_ENV !== 'production' && warn(
'Avoid adding reactive properties to a Vue instance or its root $data ' +
'at runtime - declare it upfront in the data option.'
)
return val
}
// 5.如果不是响应式的也不需要将其定义成响应式属性
if (!ob) {
target[key] = val
return val
}
// 6.将属性定义成响应式的
defineReactive(ob.value, key, val)
ob.dep.notify() // 7.通知视图更新
return val
}
小结
- 尽可能减少vue托管数据,或者减少数据的层级,递归性能很差
- 不变数据采用Object.freeze 如 后台项目渲染iview表格
- 接口数据扁平化,用啥取啥 如 日常调用接口不要直接给vue,而是过滤一下(分享自己的封装mapUtil)
- vue监听缺陷可以使用$set解决,本质上还是$splice,最好记住数组七个方法(4+2+1),以免出现问题佷蒙
- 忘记通过索引更改数组这回事儿吧,如果你在用vue的话
多则惑少则得,如果只能记住一句话:拥抱扁平等于拥抱性能
/**
* 最终版映射 避免增加多余属性,vue无用监听浪费性能
* target 源 可以是对象或是数组
* keyArr 组成可以有三种: 1.字符串 为所需要的key 2. 数组[key,newKey] 取出需要属性的同时重命名 如data.course.title -> data.title 可传递 ["course.title","title"] 3. 数组 [fn] 只存在一个函数,会传递给它源对象和目标返回对象的指针
*/
export function mapUtil(target,keyArr){
if(Object.prototype.toString.call(target) == '[object Object]'){
return objMap(originItem,keyArr)
}
else if(Object.prototype.toString.call(target) == "[object Array]") {
return arr.map(originItem=>{
return objMap(originItem,keyArr)
})
}
}
/**
* 对象映射 避免增加多余属性,vue无用监听浪费性能
* originItem 原对象
* keyArr 组成可以有三种: 1.字符串 为所需要的key 2. 数组[key,newKey] 取出需要属性的同时重命名 如data.course.title -> data.title 可传递 ["course.title","title"] 3. 数组 [fn] 只存在一个函数,会传递给它源对象和目标返回对象的指针
*/
export function objMap(originItem,keyArr){
let mapItem = {};
for (let index = 0; index < keyArr.length; index++) {
const key = keyArr[index];
// 同时重命名
if(typeof key == "object"){
try {
mapItem[key[1]] = key[0].split(".").reduce((prev,cur)=>{if(!!prev[cur]){return prev[cur]}else{throw new Error(cur + '不存在')}},originItem)
} catch (error) {
console.log(error);
mapItem[key[1]] = ""
}
}
else if(typeof key == 'function'){
key(originItem,mapItem)
}
else {
mapItem[key] = originItem[key];
}
}
return mapItem
}
实现页面渲染:$mount compilier 渲染watcher
核心思路:vue 1.0 1. 文档碎片fragment采集 2. complier 遍历vue语法(解析器);vue2.3 1. vnode替换文档碎片 2. 组件化更新
本次时间原因只讲vue1.0
目标:根据数据渲染页面
核心思路: 获取挂载点中的html结构,将{{attr}}这种形式的内容匹配并用vm.data.attr替换,然后挂载到页面上(正则匹配+文档碎片挂载避免多次操作真实dom)
- 获取挂载点及内部html
- 定义compiler方法,用定义数据与html进行比对替换,返回新的html
- 正则小tip
?:
指匹配不捕获
- 正则小tip
- 挂载到页面上(this.$mount)
核心代码
- 初始化时判断如果是根组件就挂载
if(vm.$options.el){
vm.$mount();
}
- $mount方法其实就是执行了下render方法,这里引入了Watcher,这个watcher在vue中被称为【渲染watcher】,我们目前只把他理解为直接执行第二个参数就好,后面依赖收集解释
/**
* 渲染页面 将组件进行挂载
*/
Vue.prototype.$mount = function() {
let vm = this;
let el = vm.$options.el;
el = vm.$el = query(el);
// 渲染时通过watcher进行渲染
let updateComponent = ()=>{
this._update()
}
// 渲染watcher
new Watcher(vm,updateComponent)
}
Vue.prototype._update = function (){
// console.log('更新操作')
// 用用户传入的数据 去更新视图
let vm = this;
let el = vm.$el;
// ps:虚拟dom重写区
// 要循环这个元素 将里面的内容 换成我们的数据
let node = document.createDocumentFragment();
let firstChild;
while(firstChild = el.firstChild){ // 每次拿到第一个元素就将这个元素放入到文档碎片中
node.appendChild(firstChild); // appendChild 是具有移动的功能
}
// todo 对文本进行替换
compiler(node,vm);
el.appendChild(node);
// 需要匹配{{}}的方式来进行替换
}
- 处理html(compiler)的核心代码
const defaultRE = /\{\{((?:.|\r?\n)+?)\}\}/g
/**
*
* @param {*} node 文档碎片
* @param {*} vm
* child.nodeType 1 dom节点 3 文本节点
*/
export function compiler(node,vm){ // node 就是文档碎片
let childNodes = node.childNodes; // 只有第一层 只有儿子 没有孙子
// 将类数组转化成数组
[...childNodes].forEach(child=>{ // 一种是元素 一种是文本
if(child.nodeType == 1){ //1 元素 3表示文本
compiler(child,vm); // 编译当前元素的孩子节点
}else if(child.nodeType == 3){
util.compilerText(child,vm);
}
});
}
ps:重点注意下渲染watcher进行页面渲染时一定以取值,也就是会触发属性的get,这点对依赖收集很重要,静看下文
小结
-
无论是哪个框架,或者vue1.0的文档碎片、vue2.0的vnode,都是为了说明一件事:真实dom少操作
-
计算机名句:任何解决不了或者解决很麻烦的问题,都可以用向上封装一层的思想来解决,比如这里的文档碎片(但很垃圾,vnode才是真的体现了这句话)
多则惑少则得,如果只能记住一句话:正则很重要 无论webpack还是vue、react生成AST树必存在;推荐「js正则迷你书」
实现依赖收集:属性和依赖(需执行函数)的一对多关系
问题
如果我们使用类似watch、或者$watch之类的API呢?这就要求属性改变不止渲染视图,还需要执行其他函数,如何实现?
目标:数据改变除重新渲染外,还需要执行用户定义逻辑
核心思路:一对多解耦合用发布订阅,引入Dep和Watcher的概念,Watcher是依赖,可以理解为函数;Dep则是承载每个属性对应依赖的容器,可以理解为数组;则有 属性 : dep : watcher== 1: 1 : n
- 在属性被取值时订阅当前的函数 也就是Watcher,将之存入此属性对应的Dep实例中
- 在属性取值时发布,执行当前属性对应的dep中的所有watcher
- 注意对象和数组的依赖触发逻辑的不同,如前所言,数组本身的观测只是重写了方法
延伸问题:
-
一个属性对应一个dep很简单 ,在属性的define里形成一个闭包就好了;但dep怎么收集属性需要的watcher呢?
回想上面的思路,在我们取值的时候,触发get,那也就是说,我们只要在get中能拿到需要存的watcher,就可以存入了;怎么存呢?这就是我们的Watcher是一个对象而不是直接一个函数的原因,它内部并不是直接执行函数的,而是先将当前watcher(也就是自身)挂载到Dep类的target属性上,这样在get时如果Dep.target存入,属性对应实例就将其存入自己的dep中,完成手机
-
如何避免存入多个相同的watcher?比如我取了多次相同的属性,自然会导致多次收集操作
每个watcher和dep都有自己的id,id自增,存入时如果id相同则不存入
核心代码
-
对象依赖的收集
- 依赖存入Dep.target
class Watcher { constructor ( vm: Component, expOrFn: string | Function, // updateComponent cb: Function, options?: ?Object, isRenderWatcher?: boolean ) { this.vm = vm if (isRenderWatcher) { vm._watcher = this } this.cb = cb this.id = ++uid // uid for batching // 将updateComponent 放到this.getter上 this.getter = expOrFn this.get() // 执行get方法 } get () { pushTarget(this) // Dep.target = 渲染watcher let value const vm = this.vm value = this.getter.call(vm, vm) // 开始取值 那么在get方法中就可以获取到这个全局变量Dep.target if (this.deep) { traverse(value) } popTarget() // 结束后进行清理操作 return value } }
- dep存入当前依赖
const dep = new Dep()
get: function reactiveGetter () {
const value = getter ? getter.call(obj) : val
if (Dep.target) { // 如果有watcher 将dep和watcher对应起来
dep.depend()
}
return value
}
set: function reactiveSetter (newVal) {
dep.notify(); // 当属性更新时通知dep中的所有watcher进行更新操作
}
小结
- 解耦合/1:n 优先考虑订阅发布,设计模式很重要,推荐【大话设计模式】
多则惑少则得,如果只能记住一句话:依赖收集其实就是一个事的发生会导致多个事的连环发生,后者为依赖,前者需收集
异步更新
为了防止多次更改同一个属性或者多次修改不同属性(他们依赖的watcher相同) 会导致频繁更新渲染
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
// 1.判断watcher是否已经存放了
if (has[id] == null) {
has[id] = true
// 2.将watcher存放到队列中
queue.push(watcher)
// queue the flush
if (!waiting) {
waiting = true
nextTick(flushSchedulerQueue) // 在下一队列中清空queue
}
}
}
版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 作者: 南方小菜 原文链接:https://juejin.im/post/6864471219518111752