基本結構
這裡我根據自己的了解模仿了Vue的單檔案寫法,通過給
Vue.createApp
傳入參數再挂載元素來實作頁面與資料的互動。
其中了解不免有錯,希望大佬輕噴。
收集資料
這裡将 Vue.createApp()
裡的參數叫做options
data可以是一個對象或者函數,在是函數的時候必須ruturn出一個對象,該對象裡的資料會被vm直接調用。
可以直接先擷取options,然後将裡面的data函數執行一次再把結果挂載到執行個體上,methods等對象也可以直接挂載:(這裡忽略了data是對象的情況,隻按照是函數來處理)
class Vue{
constructor() {
this.datas = Object.create(null);
}
static createApp(options){
const vm = new Vue();
vm.datas = options.data?.call(vm);
for (const key in options.methouds) {
vm.methouds[key] = options.methouds[key].bind(vm);
}
return vm;
}
}
當然這樣隻是會獲得一個Vue執行個體,上面有輸入的資料,這些資料還不會與頁面發生互動。
Vue 的響應式資料
Vue的資料雙向綁定是通過代理注入來實作的,在vue2中使用
Object.defineProperty
而到了vue3使用的是
Proxy
API。雖然用的方法不同,但核心思想是一樣的:截獲資料的改變,然後進行頁面更新。
這樣就可以試着寫出獲得代理資料的方法:
class Vue{
constructor() {}
static createApp(options){
const vm = new Vue();
const data = options.data?.call(vm);
for (const key in data) {
vm.datas[key] = vm.ref(data[key]);
}
return vm;
}
reactive(data) {
const vm = this; //! 固定VUE執行個體,不然下面的notify無法使用
return new Proxy(data, {
//todo 修改對象屬性後修改Vnode
set(target, p, value) {
target._isref
? Reflect.set(target, "value", value)
: Reflect.set(target, p, value);
//todo 在這裡通知,然後修改頁面
dep.notify(vm);
return true;
},
});
}
ref(data) {
//? 基本資料類型會被包裝為對象再進行代理
if (typeof data != "object") {
data = {
value: data,
_isref: true,
toSting() {
return this.value;
},
};
}
return this.reactive(data);
}
}
現在如果data中設定的資料發生了改變,那麼就會調用
dep.notify
來改變頁面内容。
vm代理datas等資料
因為再模闆裡是不會寫
this.datas.xxx
來調用資料的,這裡也可以使用代理來把datas中的資料放到vm上:
class Vue {
constructor() {
//! 因為vm代理了datas 以後在vm上添加新屬性會被移動到datas中,是以如果是執行個體上的屬性要像el一樣占位
this.el = "document";
this.mountHTML = "mountHTML";
this.datas = Object.create(null);
this.methouds = Object.create(null);
}
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.datas[p]._isref ? target.datas[p].value : target.datas[p];
}
},
set(target, p, value) {
if (target[p]) {
Reflect.set(target, p, value);
} else if (target.datas[p]?._isref) {
Reflect.set(target.datas[p], "value", value);
} else {
Reflect.set(target.datas, p, value);
}
return true;
},
});
//? onBeforeCreate
options.onBeforCreate?.call(vm);
const data = options.data?.call(vm);
for (const key in data) {
vm.datas[key] = vm.ref(data[key]);
}
for (const key in options.methouds) {
vm.methouds[key] = options.methouds[key].bind(vm);
}
//? onCreated
options.onCreated?.call(vm);
return vm;
}
}
這樣通過
createApp
獲得的Vue執行個體直接通路并修改收集到的datas裡的資料。
挂載
通過
Vue.createApp
可以獲得一個Vue執行個體,這樣隻需要調用執行個體中的
mount
方法就可以進行挂載了,在挂載後就馬上進行資料的渲染。
vm.mount
接收一個參數,可以是css選擇器的字元串,也可以直接是html節點:
class Vue{
constructor() {}
mount(el) {
//todo 初始化
this.init(el);
//todo 渲染資料
render(this);
return this;
}
init(el) {
this.el = this.getEl(el);
this.mountHTML = this.el.innerHTML; //? 獲得挂載時元素的模闆
}
getEl(el) {
if (!(el instanceof Element)) {
try {
return document.querySelector(el);
} catch {
throw "沒有選中挂載元素";
}
} else return el;
}
}
渲染頁面
Vue渲染頁面使用了VNode來記錄并按照它進行頁面的渲染,在每次更新資料時獲得資料更新的地方并通過diff算法來比較舊VNode和更新資料後VNode的不同來對頁面進行渲染。
這裡不做太複雜處理,直接把挂載節點的
innerHTML
作為模闆,通過正則進行捕獲并修改,然後渲染到頁面上,同時如果有通過
@ 或 v-on
綁定的事件,則按照情況進行處理:
- 如果是原生的事件,則直接添加進去;
- 如果是非原生的事件,則通過on來記錄,以後用emit來進行觸發。
export default function render(vm) {
const regexp =
/(?<tag>(?<=<)[^\/]+?(?=(>|\s)))|\{\{(\s*)(?<data>.+?)(\s*)\}\}|(?<text>(?<=>)\S+?(?=<))|(?<eName>(?<=@|(v-on:))\S+?)(=")(?<event>\S+?(?="))/g;
const fragment = document.createDocumentFragment();
let ele = {};
//? 每次比對到tag就把獲得的資訊轉成标簽
for (const result of vm.mountHTML.matchAll(regexp)) {
if (result.groups.tag && ele.tag) {
fragment.appendChild(createEle(vm, ele));
ele = {};
}
Object.assign(ele, JSON.parse(JSON.stringify(result.groups)));
}
fragment.appendChild(createEle(vm, ele)); //? 最後這裡再執行一次把最後的一個元素也渲染
ele = null;
//? 清空原來的DOM
vm.el.innerHTML = "";
vm.el.appendChild(fragment);
}
//? 放入原生事件,用字典儲存,這裡隻記錄了click
const OrangeEvents = { click: Symbol() };
/**
* 根據解析的資料建立放入文檔碎片的元素
*/
function createEle(vm, options) {
const { tag, text, data, eName, event } = options;
if (tag) {
const ele = document.createElement(tag);
if (data) {
ele.innerText = getByPath(vm, data);
}
if (text) {
ele.innerText = text;
}
if (event) {
//todo 先判斷是不是原生事件,是就直接綁定,不然用eventBinder來注冊
if (OrangeEvents[eName]) {
ele.addEventListener(eName, vm.methouds[event]);
} else {
eventBinder.off(eName); //? 因為這裡render的實作是重新全部渲染,是以要清空對應的事件緩存
eventBinder.on(eName, vm.methouds[event].bind(vm));
}
}
return ele;
}
}
/**
* 通過字元串來通路對象中的屬性
*/
function getByPath(obj, path) {
const pathArr = path.split(".");
return pathArr.reduce((result, curr) => {
return result[curr];
}, obj);
}
這裡的正則用了具名組比對符,可以通過我的這篇部落格來了解。
這裡渲染函數隻是進行簡單渲染,沒有考慮到字元和資料同時出現的情況,也沒有考慮标簽嵌套的問題,隻能平鋪标簽。。。
注冊事件
事件注冊就是一個标準的釋出訂閱者模式的實作了,可以看看我的這篇部落格(講的并不詳細)
這裡對事件綁定進行了簡化,隻保留了
on off emit
三個方法:
class Event {
constructor() {
this.collector = Object.create(null);
}
on(eName, cb) {
this.collector[eName] ? this.collector[eName].push(cb) : (this.collector[eName] = [cb]);
}
off(eName, cb) {
if (!(eName && cb)) {
this.collector = Object.create(null);
} else if (eName && !cb) {
delete this.collector[eName];
} else {
this.collector[eName].splice(this.collector[eName].indexOf(cb), 0);
}
return this;
}
emit(eName, ...arg) {
for (const cb of this.collector[eName]) {
cb(...arg);
}
}
}
const eventBinder = new Event();
export { eventBinder };
export default eventBinder.emit.bind(eventBinder); //! emit會被注冊到vm上,讓它的this始終指向eventBinder
更新頁面
有了渲染函數就可以根據資料的變化來渲染頁面了,如果一次有多個資料進行修改,那麼會觸發多次渲染函數,這是明顯的性能浪費,是以引用
任務隊列
和
鎖
的概念來保證一次操作隻會重新渲染一次頁面:
// Dep.js
export default class Dep {
constructor() {
this.lock = true;
}
notify(vm) {
//? onBeforeUpdate
//! 把更新視圖放到微任務隊列,即使多個資料改變也隻渲染一次
if (this.lock) {
this.lock = false;
//! 應該在這裡運用diff算法更新DOM樹 這裡隻是重新渲染一次頁面
nextTick(render, vm);
nextTick(() => (this.lock = true)); //? onUpdated
}
}
}
// nextTick.js
export default function nextTick(cb, ...arg) {
Promise.resolve().then(() => {
cb(...arg);
});
}
結語
代碼位址
說不定還會試着加入其它功能。