vue-toy
200行左右程式碼模擬vue實現,檢視渲染部分使用React
來代替Snabbdom
,歡迎Star。
專案地址:https://github.com/bplok20010/vue-toy
已實現的引數:
interface Options {
el: HTMLElement | string;
propsData?: Record<string, any>;
props?: string[];
name?: string;
data?: () => Record<string, any>;
methods?: Record<string, (e: Event) => void>;
computed?: Record<string, () => any>;
watch?: Record<string, (newValue: any, oldValue: any) => any>;
render: (h: typeof React.createElement) => React.ReactNode;
renderError?: (h: typeof React.createElement, error: Error) => React.ReactNode;
mounted?: () => void;
updated?: () => void;
destroyed?: () => void;
errorCaptured?: (e: Error, vm: React.ReactInstance) => void;
}
示例:
import Vue from "vue-toy";
const Hello = Vue.component({
render(h){
return h('span', null, 'vue-toy') ;
}
})
new Vue({
el: document.getElementById("root"),
data() {
return {
msg: "hello vue toy"
};
},
render(h) {
return h("h1", null, this.msg, h(Hello));
}
});
基本原理
官方原理圖:
實現基本步驟:
- 使用Observable建立觀察物件
- 定義好檢視既render函式
- 收集檢視依賴,並監聽依賴屬性
- 渲染檢視
- 重複3-4
// 建立觀察物件
// 觀察物件主要使用的是Object.defineProperty或Proxy來實現,
const data = observable({
name: 'vue-toy',
});
// 渲染模版
const render = function(){
return <h1>{data.name}</h1>
}
// 計算render的依賴屬性,
// 依賴屬性改變時,會重新計算computedFn,並執行監控函式watchFn,
// 屬性依賴計算使用棧及可以了。
// watch(computedFn, watchFn);
watch(render, function(newVNode, oldVNode){
update(newVNode, mountNode);
});
//初始渲染
mount(render(), mountNode);
// 改變觀察物件屬性,如果render依賴了該屬性,則會重新渲染
data.name = 'hello vue toy';
檢視渲染部分(既render)使用的是vdom技術,vue使用
Snabbdom
庫,vue-toy
使用的是react
來進行渲染,所以在render函式裡你可以直接使用React的JSX語法,不過別忘記import React from 'react'
,當然也可以使用preact
inferno
等 vdom庫。
由於vue的template的最終也是解析並生成render函式,模版的解析可用
htmleParser
庫來生成AST
,剩下就是解析指令並生產程式碼,由於工作量大,這裡就不具體實現,直接使用jsx。
響應式實現
一個響應式示例程式碼:
const data = Observable({
name: "none",
});
const watcher =new Watch(
data,
function computed() {
return "hello " + this.name;
},
function listener(newValue, oldValue) {
console.log("changed:", newValue, oldValue);
}
);
// changed vue-toy none
data.name = "vue-toy";
Observable實現
原始碼
觀察物件建立這裡使用Proxy實現,示例:
function Observable(data) {
return new Proxy(data, {
get(target, key) {
return target[key];
},
set(target, key, value) {
target[key] = value;
return true;
},
});
}
這就完成了一個物件的觀察,但以上示例程式碼雖然能觀察物件,但無法實現物件屬性改動後通知觀察者,這時還缺少Watch物件來計算觀察函式的屬性依賴及Notify來實現屬性變更時的通知。
Watch實現
定義如下:
Watch(data, computedFn, watchFn);
- data 為 computedFn 的 上下文 既
this
非必須 - computedFn 為觀察函式並返回觀察的資料,Watch會計算出裡面的依賴屬性。
- watchFn 當computedFn 返回內容發生改變時,watchFn會被呼叫,同時接收到新、舊值
大概實現如下:
// Watch.js
// 當前正在收集依賴的Watch
const CurrentWatchDep = {
current: null,
};
class Watch {
constructor(data, exp, fn) {
this.deps = [];
this.watchFn = fn;
this.exp = () => {
return exp.call(data);
};
// 儲存上一個依賴收集物件
const lastWatchDep = CurrentWatchDep.current;
// 設定當前依賴收集物件
CurrentWatchDep.current = this;
// 開始收集依賴,並獲取觀察函式返回的值
this.last = this.exp();
// 還原
CurrentWatchDep.current = lastWatchDep;
}
clearDeps() {
this.deps.forEach((cb) => cb());
this.deps = [];
}
// 監聽依賴屬性的改動,並儲存取消回撥
addDep(notify) {
// 當依賴屬性改變時,重新觸發依賴計算
this.deps.push(notify.sub(() => {
this.check();
}));
}
// 重新執行依賴計算
check() {
// 清空所有依賴,重新計算
this.clearDeps();
// 作用同建構函式
const lastWatchDep = CurrentWatchDep.current;
CurrentWatchDep.current = this;
const newValue = this.exp();
CurrentWatchDep.current = lastWatchDep;
const oldValue = this.last;
// 對比新舊值是否改變
if (!shallowequal(oldValue, newValue)) {
this.last = newValue;
// 呼叫監聽函式
this.watchFn(newValue, oldValue);
}
}
}
Notify實現
觀察物件發生改變後需要通知監聽者,所以還需要實現通知者Notify:
class Notify {
constructor() {
this.listeners = [];
}
sub(fn) {
this.listeners.push(fn);
return () => {
const idx = this.listeners.indexOf(fn);
if (idx === -1)
return;
this.listeners.splice(idx, 1);
};
}
pub() {
this.listeners.forEach((fn) => fn());
}
}
調整Observable
前面的Observable
太簡單了,無法完成屬性計算的需求,結合上面Watch
Notify
的來調整下Observable。
function Observable(data) {
const protoListeners = Object.create(null);
// 給觀察資料的所有屬性建立一個Notify
each(data, (_, key) => {
protoListeners[key] = new Notify();
});
return new Proxy(data, {
get(target, key) {
// 屬性依賴計算
if (CurrentWatchDep.current) {
const watcher = CurrentWatchDep.current;
watcher.addDep(protoListener[key]);
}
return target[key];
},
set(target, key, value) {
target[key] = value;
if (protoListeners[key]) {
// 通知所有監聽者
protoListeners[key].pub();
}
return true;
},
});
}
好了,觀察者的建立和訂閱都完成了,開始模擬Vue。
模擬Vue
vue-toy
使用React
來實現檢視的渲染,所以render函式裡如果使用JSX則需要引入React
準備
既然已經實現了Observable和Watch,那我們就來實現基本原理的示例:
import Observable from "vue-toy/cjs/Observable";
import Watch from "vue-toy/cjs/Watch";
function mount(vnode) {
console.log(vnode);
}
function update(vnode) {
console.log(vnode);
}
const data = Observable({
msg: "hello vue toy!",
counter: 1
});
function render() {
return `render: ${this.counter} | ${this.msg}`;
}
new Watch(data, render, update);
mount(render.call(data));
setInterval(() => data.counter++, 1000);
// 在控制檯可看到每秒的輸出資訊
這時將mount update的實現換成vdom就可以完成一個基本的渲染。
但這還不夠,我們需要抽象並封裝成元件來用。
Component
這裡的Component像是React的高階函式HOC,使用示例:
const Hello = Component({
props: ["msg"],
data() {
return {
counter: 1,
};
},
render(h) {
return h("h1", null, this.msg, this.counter);
},
});
大概實現如下,options
參考文章開頭
function Component(options) {
return class extends React.Component {
// 省略若干...
constructor(props) {
super(props);
// 省略若干...
// 建立觀察物件
this.$data = Observable({ ...propsData, ...methods, ...data }, computed);
// 省略若干...
// 計算render依賴並監聽
this.$watcher = new Watch(
this.$data,
() => {
return options.render.call(this, React.createElement);
},
debounce((children) => {
this.$children = children;
this.forceUpdate();
})
);
this.$children = options.render.call(this, React.createElement);
}
shouldComponentUpdate(nextProps) {
if (
!shallowequal(
pick(this.props, options.props || []),
pick(nextProps, options.props || [])
)
) {
this.updateProps(nextProps);
this.$children = options.render.call(this, React.createElement);
return true;
}
return false;
}
// 生命週期關聯
componentDidMount() {
options.mounted?.call(this);
}
componentWillUnmount() {
this.$watcher.clearDeps();
options.destroyed?.call(this);
}
componentDidUpdate() {
options.updated?.call(this);
}
render() {
return this.$children;
}
};
}
建立主函式 Vue
最後建立入口函式Vue,實現程式碼如下:
export default function Vue(options) {
const RootComponent = Component(options);
let el;
if (typeof el === "string") {
el = document.querySelector(el);
}
const props = {
...options.propsData,
$el: el,
};
return ReactDOM.render(React.createElement(RootComponent, props), el);
}
Vue.component = Component;
好了,Vue的基本實現完成了。
感謝閱讀。
最後,歡迎Star:https://github.com/bplok20010/vue-toy