vue-toy: 200行程式碼模擬Vue實現

隨心E發表於2020-06-14

vue-toy

200行左右程式碼模擬vue實現,檢視渲染部分使用React來代替Snabbdom,歡迎Star。
專案地址:https://github.com/bplok20010/vue-toy

codesandbox示例

已實現的引數:

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));
  }
});

基本原理

官方原理圖:
在這裡插入圖片描述
實現基本步驟:

  1. 使用Observable建立觀察物件
  2. 定義好檢視既render函式
  3. 收集檢視依賴,並監聽依賴屬性
  4. 渲染檢視
  5. 重複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,那我們就來實現基本原理的示例:

codesandbox示例

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

相關文章