Vue原始碼分析之實現一個簡易版的Vue

Khazix發表於2020-08-23

目標

使用 Typescript 編寫簡易版的 vue 實現資料的響應式和基本的檢視渲染,以及雙向繫結功能。

參考 https://cn.vuejs.org/v2/guide/reactivity.html

測試程式碼中,編寫vue.js是本篇的重點,基本使用方法與常規的Vue一樣:

<div id='app'>
    <div>{{ person.name }}</div>
    <div>{{ count }}</div>
    <div v-text='person.name'></div>
    <input type='text' v-model='msg' />
    <input type='text' v-model='person.name'/>
</div>

<script src='vue.js'></script>
<script>
let vm = new Vue({
    el: '#app',
    data: {
        msg: 'Hello vue',
        count: 100,
        person: { name: 'Tim' },
    }
});
vm.msg = 'Hello world';
console.log(vm);

//模擬資料更新
setTimeout(() => { vm.person.name = 'Goooooood'; }, 1000);
<script>

頁面渲染結果如下

實現的簡易Vue需要完成以下功能

  • 可以解析插值表示式,如 {{person.name}}
  • 可以解析內建指令,如 v-text
  • 可以雙向繫結資料,如 v-model
  • 資料更新檢視隨之更新

Vue當中有以下重要的元件

  1. 初始化時通過 Object.defineProperty 代理Vue.data的資料方便操作, 訪問Vue.prop等於訪問Vue.data.prop
  2. 通過 ObserverVue.data 裡所有的資料及其子節點(遞迴)都進行捕捉,通過getter setter實現資料雙向繫結
  3. 初始 Observergetter中收集依賴(watcher觀察者)在setter中傳送通知notify
  4. Watcher 中註冊依賴Dep

基層Vue

Vue 資料結構,這裡只關注下面三個屬性

欄位 說明
$options 存放構造時傳入的配置引數
$data 存放資料
$el 存放需要渲染的元素

實現Vue時,需要完成以下功能:

  • 負責接收初始化引數 options
  • 負責把data屬性注入到vue,並轉換成 getter/setter
  • 負責呼叫observer監聽所有屬性變化
  • 負責呼叫compiler解析指令和差值表示式

型別介面定以

為保持靈活性,這裡直接用any型別

interface VueData {
    [key: string]: any,
}

interface VueOptions {
    data: VueData;
    el: string | Element;
}

interface Vue {
    [key: string]: any,
}

Vue實現程式碼

class Vue {
    public $options: VueOptions;
    public $data: VueData;
    public $el: Element | null;

    public constructor(options: VueOptions) {
        this.$options = options;
        this.$data = options.data || {};
        if (typeof options.el == 'string') {
            this.$el = document.querySelector(options.el);
        } else {
            this.$el = options.el;
        }

        if (!this.$el) {
            throw Error(`cannot find element by selector ${options.el}`);
            return;
        }
        this._proxyData(this.$data);
    }

    //生成代理,通過直接讀寫vue屬性來代理vue.$data的資料,提高便利性
    //vue[key] => vue.data[key]
    private _proxyData(data: VueData) {
        Object.keys(data).forEach(key => {
            Object.defineProperty(this, key, {
                enumerable: true,
                configurable: true,
                get() {
                    return data[key];
                },
                set(newVal) {
                    if (newVal == data[key]) {
                        return;
                    }
                    data[key] = newVal;
                }
            })
        })
    }
}

  • 對於Vue的後設資料均以$開頭表示,因為訪問 Vue.data 會被代理成 Vue.$data.data,即注入屬性與元屬性進行區分
  • $el 可以為選擇器或Dom,但最終需要轉成Dom,若不存在Dom丟擲錯誤
  • _porxyData,下劃線開頭為私有屬性或方法,此方法可以將 $data 屬性注入到vue中
  • enumerable 為可列舉, configurable 為可配置,如重定以和刪除屬性
  • setter 中,如果資料沒有發生變化則return,發生變化更新 $data

簡單測試一下

let vm = new Vue({
    el: '#app',
    data: {
        msg: 'Hello vue',
        count: 100,
        person: { name: 'Tim' },
    }
});

上圖中顏色比較幽暗的,表示注入到Vue的屬性已成功設定了getter和setter

Observer

  • 負責把data選項中的屬性轉換成響應式資料
  • data中某個屬性的值也是物件,需要遞迴轉換成響應式
  • 資料發生變化時傳送通知

Observer 實現程式碼

class Observer {
    constructor(data) {
        this.walk(data);
    }
    walk(data) {
        Object.keys(data).forEach(key => {
            this.defineReactive(data, key, data[key]);
        });
    }
    defineReactive(obj, key, val) {
        //遞迴處理成響應式
        if (typeof val === 'object') {
            this.walk(val);
        }
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get() {
                //注意:這裡val不可改成obj[key],會無限遞迴直至堆疊溢位
                return val;
            },
            set: (newVal) => {
                if (newVal == val) {
                    return;
                }
                //注意:這裡newVal不可改成obj[key],會觸發 getter
                val = newVal;
                if (typeof newVal == 'object') {
                    this.walk(newVal);
                }
            }
        });
    }
}
  • walk方法 用於遍歷$data屬性,傳遞給defineReactive做響應式處理
  • defineReactive 如果值為物件則遞迴呼叫walk,如果值為原生資料則設定getter和setter

關於defineReactive(data, key, val)中的形參val

形參中的 val 等同於 data[key],這裡千萬不能在getter或setter內部中使用 data[key]獲取值,會造成無需遞迴導致堆疊溢位

因而需要在defineReactive呼叫前訪問 data[key] 並將其值以 val形參傳遞內部使用中。 這個val會一直存放在閉包空間當中

Observer 引用

在上面編寫的 Vue.constructor 中新增Observer的引用,並傳入$data

    //Vue.constructor
    public constructor(options: VueOptions) {
        this.$options = options;
        this.$data = options.data || {};
        if (typeof options.el == 'string') {
            this.$el = document.querySelector(options.el);
        } else {
            this.$el = options.el;
        }

        if (!this.$el) {
            throw Error(`cannot find element by selector ${options.el}`);
            return;
        }
        this._proxyData(this.$data);
        new Observer(this.$data);          //新增此行
    }

測試

重新列印vm可以看到 $data 裡的成員也有getter和setter方法了

Compiler

  • 負責編譯模板,解析指令v-xxx和插值表示式{{var}}
  • 負責頁面首次渲染
  • 當資料發生變化時,重新渲染檢視

注意,為簡化程式碼,這裡的插值表示式,不處理複雜情況,只處理單一的變數讀取

{{count + 2}} => 不進行處理
{{person.name}} => 可以處理

Util 輔助工具

為方便操作,我們需要提前編寫幾個簡單的函式功能,並封裝到 Util 類中靜態方法裡

class Util {
    static isPrimitive(s: any): s is (string | number) {
        return typeof s === 'string' || typeof s === 'number';
    }

    static isHTMLInputElement(element: Element): element is HTMLInputElement {
        return (<HTMLInputElement>element).tagName === 'INPUT';
    }

    //處理無法引用 vm.$data['person.name'] 情況
    static getLeafData(obj: Object, key: string): any {
        let textData: Array<any> | Object | String | Number = obj;

        if (key.indexOf('.') >= 0) {
            let keys = key.split('.');
            for (let k of keys) {
                textData = textData[k];
            }
        } else {
            textData = obj[key];
        }

        return textData;
    }

    static setLeafData(obj: Object, key: string, value: any): void {
        if (key.indexOf('.') >= 0) {
            let keys = key.split('.');

            for (let i = 0; i < keys.length; i++) {
                let k = keys[i];
                if (i == keys.length - 1) {
                    obj[k] = value;
                } else {
                    obj = obj[k];
                }

            }
        } else {
            if (obj[key]){
                obj[key] = value;
            }
        }
    }
}
  • isPrimitive

該函式用於判斷變數是否為原生型別(string or number)

  • isHTMLInputElement

該函式用於判斷元素是否為Input元素,用於後面處理 v-model 指令的雙向繫結資料,預設:value @input

  • getLeafData

因為key可能為 person.name, 如果直接中括號訪問物件屬性如 obj['person.name'] 無法等同於 obj.person.name

該函式如果傳遞的鍵key中,若不包含點.,則直接返回 obj[key]。 若包含,則解析處理返回 obj.key1.key2.key3

  • setLeafData

同上, key為person.name時,設定 obj.person.name = value,否則設定 obj.key = value

Complier 實現程式碼

class Compiler {
    public el: Element | null;
    public vm: Vue;

    constructor(vm: Vue) {
        this.el = vm.$el,
            this.vm = vm;
        if (this.el) {
            this.compile(this.el);
        }
    }

    compile(el: Element) {
        let childNodes = el.childNodes;
        Array.from(childNodes).forEach((node: Element) => {
            if (this.isTextNode(node)) {
                this.compileText(node);
            } else if (this.isElementNode(node)) {
                this.compileElement(node);
            }

            //遞迴處理孩子nodes
            if (node.childNodes && node.childNodes.length !== 0) {
                this.compile(node);
            }
        })
    }

    //解析插值表示式 {{text}}
    compileText(node: Node) {
        let pattern: RegExpExecArray | null;
        if (node.textContent && (pattern = /\{\{(.*?)\}\}/.exec(node.textContent))) {
            let key = pattern[1].trim();
            if (key in this.vm.$data && Util.isPrimitive(this.vm.$data[key])) {
                node.textContent = this.vm.$data[key];
            }
        }
    }

    //解析 v-attr 指令
    compileElement(node: Element) {
        Array.from(node.attributes).forEach((attr) => {
            if (this.isDirective(attr.name)) {
                let directive: string = attr.name.substr(2);
                let value = attr.value;
                let processer: Function = this[directive + 'Updater'];
                if (processer) {
                    processer.call(this, node, value);
                }

            }
        })
    }

    //處理 v-model 指令
    modelUpdater(node: Element, key: string) {
        if (Util.isHTMLInputElement(node)) {
            let value = Util.getLeafData(this.vm.$data, key);
            if (Util.isPrimitive(value)) {
                node.value = value.toString();
            }

            node.addEventListener('input', () => {
                Util.setLeafData(this.vm.$data, key, node.value);
                console.log(this.vm.$data);
            })
        }
    }

    //處理 v-text 指令
    textUpdater(node: Element, key: string) {
        let value = Util.getLeafData(this.vm.$data, key);
        if (Util.isPrimitive(value)) {
            node.textContent = value.toString();
        }
    }

    //屬性名包含 v-字首代表指令
    isDirective(attrName: string) {
        return attrName.startsWith('v-');
    }

    //nodeType為3屬於文字節點
    isTextNode(node: Node) {
        return node.nodeType == 3;
    }

    //nodeType為1屬於元素節點
    isElementNode(node: Node) {
        return node.nodeType == 1;
    }
}
  • compile

用於首次渲染傳入的 div#app 元素, 遍歷所有第一層子節點,判斷子節點nodeType屬於文字還是元素

若屬於 文字 則呼叫 compileText 進行處理, 若屬於 元素 則呼叫 compileElement 進行處理。

另外如果子節點的孩子節點 childNodes.length != 0 則遞迴呼叫 compile(node)

  • compileText

用於渲染插值表示式,使用正則 \{\{(.*?)\}\} 檢查是否包含插值表示式,提取括號內變數名

通過工具函式 Utils.getLeafData(vm.$data, key) 嘗試讀取 vm.$data[key]vm.$data.key1.key2 的值

如果能讀取成功,則渲染到檢視當中 node.textContent = this.vm.$data[key];

  • compileElement

用於處理內建v-指令,通過 node.attributes 獲取所有元素指令,Array.from() 可以使NamedNodeMap轉成可遍歷的陣列

獲取屬性名,判斷是否有 v- 字首,若存在則進行解析成函式,解析規則如下

  • v-text 解析的函式名為 textUpdater()
  • v-model 解析函式名為 modelUpdater()

可以通過嘗試方法獲取,如 this[directive + "Updater"] 若不為 undefined 說明指令處理函式是存在的

最後通過 call 呼叫,使得 this 指向 Compiler類例項

  • textUpdater

與 compileText 類似,嘗試讀取變數並渲染到Dom中

  • modelUpdate

除了嘗試讀取變數並渲染到Dom中,還需要設定 @input 函式監聽檢視的變化來更新資料

node.addEventListener('input', () => {
    Util.setLeafData(this.vm.$data, key, node.value);
})

Complier 例項化引用

在 Vue.constructor 中引用 Compiler 進行首次頁面渲染

    //Vue.constructor
    public constructor(options: VueOptions) {
        this.$options = options;
        this.$data = options.data || {};
        if (typeof options.el == 'string') {
            this.$el = document.querySelector(options.el);
        } else {
            this.$el = options.el;
        }

        if (!this.$el) {
            throw Error(`cannot find element by selector ${options.el}`);
            return;
        }
        this._proxyData(this.$data);
        new Observer(this.$data);
        new Compiler(this);                  //新增此行
    }

測試程式碼

<div id='app'>
    <div>{{ person.name }}</div>
    <div>{{ count }}</div>
    <div v-text='person.name'></div>
    <input type='text' v-model='msg' />
    <input type='text' v-model='person.name'/>
</div>
<script src='vue.js'></script>
<script>

let vm = new Vue({
    el: '#app',
    data: {
        msg: 'Hello vue',
        count: 100,
        person: { name: 'tim' },
    }
})
</scirpt>

渲染結果

至此完成了初始化資料驅動和渲染功能,我們修改 input 表單裡的元素內容是會通過 @input動態更新$data對應繫結v-model的資料

但是此時我們在控制檯中修改 vm.msg = 'Gooooood' ,檢視是不會有響應式變化的,因此下面將通過WatcherDep 觀察者模式來實現響應式處理

Watcher 與 Dep

Dep(Dependency)

實現功能:

  • 收集依賴,新增觀察者(Watcher)
  • 通知所有的觀察者 (notify)

Dep 實現程式碼

class Dep {
    static target: Watcher | null;
    watcherList: Watcher[] = [];

    addWatcher(watcher: Watcher) {
        this.watcherList.push(watcher);
    }

    notify() {
        this.watcherList.forEach((watcher) => {
            watcher.update();
        })
    }
}

Watcher

實現功能:

  • 當變化觸發依賴時,Dep通知Watcher進行更新檢視
  • 當自身例項化時,向Dep中新增自己

Watcher 實現程式碼

每個觀察者Watcher都必須包含 update方法,用於描述資料變動時如何響應式渲染到頁面中

class Watcher {
    public vm: Vue;
    public cb: Function;
    public key: string;
    public oldValue: any;

    constructor(vm: Vue, key: string, cb: Function) {
        this.vm = vm;
        this.key = key;
        this.cb = cb;

        //註冊依賴
        Dep.target = this;

        //訪問屬性觸發getter,收集target
        this.oldValue = Util.getLeafData(vm.$data, key);

        //防止重複新增
        Dep.target = null;
    }

    update() {
        let newVal = Util.getLeafData(this.vm.$data, this.key);

        if (this.oldValue == newVal) {
            return;
        }

        this.cb(newVal);
    }
}

修改 Observer.defineReactive

對於$data中每一個屬性,都對應著一個 Dep,因此我們需要在$data初始化響應式時建立Dep例項,在getter 中收集觀察者Dep.addWatcher(), 在 setter 中通知觀察者 Dep.notify()

  
    defineReactive(obj: VueData, key: string, val: any) {
        let dep = new Dep();                        //新增此行,每個$data中的屬性都對應一個Dep例項化

        //如果data值的為物件,遞迴walk
        if (typeof val === 'object') {
            this.walk(val);
        }
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get() {
                Dep.target && dep.addWatcher(Dep.target);       //檢查是否有Watcher,收集依賴的觀察者
                //此處不能返回 obj[key] 會無限遞迴觸發get
                console.log('getter')
                return val;
            },
            set: (newVal) => {
                if (newVal == val) {
                    return;
                }
                val = newVal;
                if (typeof newVal == 'object') {
                    this.walk(newVal)
                }

                //傳送通知
                dep.notify();                        //新增此行,$data中屬性傳送變動時傳送通知
            }
        });
    }

修改 Compiler類,下面幾個方法均新增例項化Watcher

每個檢視對應一個Watcher,以key為關鍵字觸發響應的Dep,並通過getter將Watcher新增至Dep中


class Compiler {
    //插值表示式
    compileText(node: Node) {
        let pattern: RegExpExecArray | null;
        if (node.textContent && (pattern = /\{\{(.*?)\}\}/.exec(node.textContent))) {
            let key = pattern[1].trim();
            let value = Util.getLeafData(this.vm.$data, key);
            if (Util.isPrimitive(value)) {
                node.textContent = value.toString();
            }
            new Watcher(this.vm, key, (newVal: string) => { node.textContent = newVal; });   //新增此行
        }
    }

    //v-model
    modelUpdater(node: Element, key: string) {
        if (Util.isHTMLInputElement(node)) {
            let value = Util.getLeafData(this.vm.$data, key);
            if (Util.isPrimitive(value)) {
                node.value = value.toString();
            }

            node.addEventListener('input', () => {
                Util.setLeafData(this.vm.$data, key, node.value);
                console.log(this.vm.$data);
            })

            new Watcher(this.vm, key, (newVal: string) => { node.value = newVal; });  //新增此行
        }
    }

    //v-text
    textUpdater(node: Element, key: string) {
        let value = Util.getLeafData(this.vm.$data, key);
        if (Util.isPrimitive(value)) {
            node.textContent = value.toString();
        }

        new Watcher(this.vm, key, (newVal: string) => { node.textContent = newVal; });   //新增此行
    }
}

至此本篇目的已經完成,實現簡易版Vue的響應式資料渲染檢視和雙向繫結,下面是完整 ts程式碼和測試程式碼

實現簡易版Vue完整程式碼

//main.ts 這裡要自己編譯成 main.js
interface VueData {
    [key: string]: any,
}

interface VueOptions {
    data: VueData;
    el: string | Element;
}

interface Vue {
    [key: string]: any,
}

class Util {
    static isPrimitive(s: any): s is (string | number) {
        return typeof s === 'string' || typeof s === 'number';
    }

    static isHTMLInputElement(element: Element): element is HTMLInputElement {
        return (<HTMLInputElement>element).tagName === 'INPUT';
    }

    //處理無法引用 vm.$data['person.name'] 情況
    static getLeafData(obj: Object, key: string): any {
        let textData: Array<any> | Object | String | Number = obj;

        if (key.indexOf('.') >= 0) {
            let keys = key.split('.');
            for (let k of keys) {
                textData = textData[k];
            }
        } else {
            textData = obj[key];
        }

        return textData;
    }

    static setLeafData(obj: Object, key: string, value: any): void {
        if (key.indexOf('.') >= 0) {
            let keys = key.split('.');

            for (let i = 0; i < keys.length; i++) {
                let k = keys[i];
                if (i == keys.length - 1) {
                    obj[k] = value;
                } else {
                    obj = obj[k];
                }

            }
        } else {
            if (obj[key]){
                obj[key] = value;
            }
        }
    }
}

class Vue {
    public $options: VueOptions;
    public $data: VueData;
    public $el: Element | null;

    public constructor(options: VueOptions) {
        this.$options = options;
        this.$data = options.data || {};
        if (typeof options.el == 'string') {
            this.$el = document.querySelector(options.el);
        } else {
            this.$el = options.el;
        }

        if (!this.$el) {
            throw Error(`cannot find element by selector ${options.el}`);
            return;
        }
        this._proxyData(this.$data);
        new Observer(this.$data);
        new Compiler(this);
    }

    //生成代理,通過直接讀寫vue屬性來代理vue.$data的資料,提高便利性
    //vue[key] => vue.data[key]
    private _proxyData(data: VueData) {
        Object.keys(data).forEach(key => {
            Object.defineProperty(this, key, {
                enumerable: true,
                configurable: true,
                get() {
                    return data[key];
                },
                set(newVal) {
                    if (newVal == data[key]) {
                        return;
                    }
                    data[key] = newVal;
                }
            })
        })
    }
}

class Observer {
    constructor(data: VueData) {
        this.walk(data);
    }

    walk(data: VueData) {
        Object.keys(data).forEach(key => {
            this.defineReactive(data, key, data[key]);
        });
    }

    //觀察vue.data的變化,並同步渲染至檢視中
    defineReactive(obj: VueData, key: string, val: any) {
        let dep = new Dep();


        //如果data值的為物件,遞迴walk
        if (typeof val === 'object') {
            this.walk(val);
        }
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get() {
                //收集依賴
                Dep.target && dep.addWatcher(Dep.target);
                //此處不能返回 obj[key] 會無限遞迴觸發get
                console.log('getter')
                return val;
            },
            set: (newVal) => {
                if (newVal == val) {
                    return;
                }
                val = newVal;
                if (typeof newVal == 'object') {
                    this.walk(newVal)
                }

                //傳送通知
                dep.notify();
            }
        });
    }
}

class Compiler {
    public el: Element | null;
    public vm: Vue;

    constructor(vm: Vue) {
        this.el = vm.$el,
            this.vm = vm;
        if (this.el) {
            this.compile(this.el);
        }
    }

    compile(el: Element) {
        let childNodes = el.childNodes;
        Array.from(childNodes).forEach((node: Element) => {
            if (this.isTextNode(node)) {
                this.compileText(node);
            } else if (this.isElementNode(node)) {
                this.compileElement(node);
            }

            //遞迴處理孩子nodes
            if (node.childNodes && node.childNodes.length !== 0) {
                this.compile(node);
            }
        })
    }

    // {{text}}
    compileText(node: Node) {
        let pattern: RegExpExecArray | null;
        if (node.textContent && (pattern = /\{\{(.*?)\}\}/.exec(node.textContent))) {
            let key = pattern[1].trim();
            let value = Util.getLeafData(this.vm.$data, key);
            if (Util.isPrimitive(value)) {
                node.textContent = value.toString();
            }
            new Watcher(this.vm, key, (newVal: string) => { node.textContent = newVal; })
        }
    }

    //v-attr
    compileElement(node: Element) {
        Array.from(node.attributes).forEach((attr) => {
            if (this.isDirective(attr.name)) {
                let directive: string = attr.name.substr(2);
                let value = attr.value;
                let processer: Function = this[directive + 'Updater'];
                if (processer) {
                    processer.call(this, node, value);
                }

            }
        })
    }

    //v-model
    modelUpdater(node: Element, key: string) {
        if (Util.isHTMLInputElement(node)) {
            let value = Util.getLeafData(this.vm.$data, key);
            if (Util.isPrimitive(value)) {
                node.value = value.toString();
            }

            node.addEventListener('input', () => {
                Util.setLeafData(this.vm.$data, key, node.value);
                console.log(this.vm.$data);
            })

            new Watcher(this.vm, key, (newVal: string) => { node.value = newVal; })
        }
    }

    //v-text
    textUpdater(node: Element, key: string) {
        let value = Util.getLeafData(this.vm.$data, key);
        if (Util.isPrimitive(value)) {
            node.textContent = value.toString();
        }

        new Watcher(this.vm, key, (newVal: string) => {
            node.textContent = newVal;
        });
    }

    isDirective(attrName: string) {
        return attrName.startsWith('v-');
    }

    isTextNode(node: Node) {
        return node.nodeType == 3;
    }

    isElementNode(node: Node) {
        return node.nodeType == 1;
    }
}

class Dep {
    static target: Watcher | null;
    watcherList: Watcher[] = [];

    addWatcher(watcher: Watcher) {
        this.watcherList.push(watcher);
    }

    notify() {
        this.watcherList.forEach((watcher) => {
            watcher.update();
        })
    }
}

class Watcher {
    public vm: Vue;
    public cb: Function;
    public key: string;
    public oldValue: any;

    constructor(vm: Vue, key: string, cb: Function) {
        this.vm = vm;
        this.key = key;
        this.cb = cb;

        //註冊依賴
        Dep.target = this;

        //訪問屬性觸發getter,收集target
        this.oldValue = Util.getLeafData(vm.$data, key);

        //防止重複新增
        Dep.target = null;
    }

    update() {
        let newVal = Util.getLeafData(this.vm.$data, this.key);

        if (this.oldValue == newVal) {
            return;
        }

        this.cb(newVal);
    }
}



測試程式碼

<div id='app'>
    <div>{{ person.name }}</div>
    <div>{{ count }}</div>
    <div v-text='person.name'></div>
    <input type='text' v-model='msg' />
    <input type='text' v-model='person.name'/>
</div>
    <script src='dist/main.js'></script>
<script>
let vm = new Vue({
    el: '#app',
    data: {
        msg: 'Hello vue',
        count: 100,
        person: { name: 'tim' },
    }
})

// vm.msg = 'Hello world';
console.log(vm);

setTimeout(() => { vm.person.name = 'Goooooood' }, 1000);
</scirpt>

相關文章