該版把上一次的數據修改就更新全部頁面改為了局部更新,相比於上一版的在數據綁定上不是簡單的一個監聽set
再全部更新,具體見下文。
總體流程
仍然是根據自己理解來實現的綁定,相較於上一版的數據更新就全部刷新,這次改成了部分頁面更改,總體流程大致如圖:(字本來就丑,那個筆芯寫更丑了,希望能看懂吧)
這里就從頭介紹下怎樣實現整個流程的
createApp
這里是整個Vue的入口,通過傳入options
參數會將里面的data,methods
等掛載到Vue實例上,再通過代理,讓對vm
的屬性訪問轉換為對vm.$data
中屬性的訪問:
static createApp(options) {
//? 將data代理到vm上
const vm = new Proxy(new Vue(), {
get(target, p) {
if (Reflect.get(target, p)) {
return Reflect.get(target, p);
} else {
return target.$data[p]._isref ? target.$data[p].value : target.$data[p];
}
},
set(target, p, value) {
if (target[p]) {
Reflect.set(target, p, value);
} else if (target.$data[p]?._isref) {
Reflect.set(target.$data[p], "value", value);
} else {
Reflect.set(target.$data, p, value);
}
return true;
},
});
options.onBeforCreate?.call(vm);
vm.$data = options.data.call(vm);
new Observer(vm).observeData(); //! 將data的數據轉為響應式
for (const key in options.methouds) {
vm.$methouds[key] = options.methouds[key].bind(vm);
}
options.onCreated?.call(vm);
return vm;
}
將data中的數據轉換為響應式
這個步驟通過Observer
實例中的observeData
來進行,我這里通過Proxy
來實現(Vue2.x中使用Object.defineProperty
)。
import Dep from "./dep.js";
const dep = new Dep();
export default class Observer {
constructor(vm) {
this.vm = vm;
}
observeData() {
const data = this.vm.$data;
for (const key in data) {
data[key] = this.ref(data[key]);
}
}
// *===============↓ 將數據轉換為響應式數據的方法 ↓===============* //
reactive(data) {
//? 如果對象里還有對象,遞歸實現響應式
for (const key in data) {
if (typeof data[key] === "object") {
data[key] = this.reactive(data[key]);
}
}
return new Proxy(data, {
get(target, p) {
window.target && dep.add(window.target);
window.target = null; //? 將watch實例保存后刪除
return Reflect.get(target, p);
},
//todo 修改對象屬性后修改Vnode
set(target, p, value) {
target._isref
? Reflect.set(target, "value", value)
: Reflect.set(target, p, value);
dep.notify();
return true;
},
});
}
ref(data) {
//? 基本數據類型會被包裝為對象再進行代理
if (typeof data != "object") {
data = {
value: data,
_isref: true,
toSting() {
return this.value;
},
};
}
return this.reactive(data);
}
}
這里在get
上設置了dep.add
,在第一次渲染頁面的時候會讀取到對應的$data
中的屬性,在這個時候將這個屬性的位置和一個用來更新視圖的回調函數打包進Watcher
的實例再放入dep
中儲存起來,在以后數據更新時會觸發set
,通知dep
調用儲存的所有watcher
實例上的update
方法,update
方法會比較儲存的舊值來決定是否觸發回調函數來更新視圖。
Dep:
import { nextTick } from "./util.js";
export default class Dep {
constructor() {
this.watchers = [];
this.lock = true;
}
add(watcher) {
this.watchers.push(watcher);
}
notify() {
//? 放入微任務隊列,只要觸發一次notify就不再觸發,在微任務里更新視圖,這樣所有數據都更新后再觸發更新
if (this.lock) {
this.lock = false;
nextTick(() => {
this.watchers.forEach((watcher) => {
watcher.update(); //? 用watcher實例的update更新視圖
});
this.lock = true;
});
}
}
}
Watcher:
import { getByPath } from "./util.js";
export default class Watcher {
constructor(vm, key, cb) {
this.vm = vm;
this.key = key; //? 代表該數據在$data哪里的字符串
this.cb = cb; //? 更新頁面的回調函數
window.target = this;
//! 獲得舊數據,同時觸發vm[key]的get把上面一行設置watcher實例push進dep 見observer.js
this.oldValue = getByPath(vm, key);
}
//? dep調用notify來調用所有的update更新視圖
update() {
let newValue = getByPath(this.vm, this.key);
if (newValue === this.oldValue) return;
this.oldValue = newValue;
this.cb(newValue);
}
}
為了使用方便,這里把Watcher的實例化過程掛載到vm上,實例化Watcher並推入dep的過程全由vm.$watche
完成:
class Vue {
constructor() {
this.$watch = function (key, cb) {
new Watcher(this, key, cb);
};
}
}
頁面渲染
通過修改原來的第一版渲染函數,這里改為了挨個讀取節點來轉換,通過讀取每個節點的字符串形式來把數據替換或把方法掛載:
export default function render($el, vm) {
const nodes = $el.children;
Array.prototype.forEach.call(nodes, (el) => {
if (el.children.length > 0) {
render(el, vm); //? 遞歸渲染子節點
} else {
renderTemplate(vm, el);
}
});
}
function renderTemplate(vm, el) {
renderData(vm, el);
renderEvent(vm, el);
renderVModel(vm, el);
}
//? 將{{}}里的數據渲染
function renderData(vm, el) {
const nodeText = el.textContent;
const regexp = /\{\{(\s*)(?<data>.+?)(\s*)\}\}/g;
if (regexp.test(nodeText)) {
return nodeText.replace(regexp, (...arg) => {
const groups = JSON.parse(JSON.stringify(arg.pop()));
//! 將這個數據相對於vm的位置儲存進dep,每次dep收到更新時觸發回調
vm.$watch(groups.data, (newValue) => {
el.textContent = newValue;
});
el.textContent = getByPath(vm, groups.data);
});
}
}
... ...
再說明一下,現在的渲染操作只在進行mount
的時候會執行,當以后$data
屬性改變時會觸發在這里設置的回調函數,通過它來修改頁面。
一些其它細節的地方
在頁面渲染時讀取$data
屬性只能通過寫在模板上的字符串,這里用了reduce
方法來獲取字符串對應的值:
export function getByPath(obj, path) {
const pathArr = path.split(".");
return pathArr.reduce((result, curr) => {
return result[curr];
}, obj);
}
nextTick函數在這里只是用了開啟微任務隊列的方式實現:
export function nextTick(cb, ...arg) {
Promise.resolve().then(() => {
cb(...arg);
});
}
測試
最后簡單寫個計數器來看看實現的所有功能,可以看到和預期的一樣