終極蛇皮上帝視角之微信小程序之告別 setData

衆所周知 Vue 是藉助 ES5 的 Object.defineProperty 方法設置 getter、setter 達到數據驅動界面,固然其中還有模板編譯等等其餘過程。javascript

而小程序官方的 api 是在 Page 中調用 this.setData 方法來改變數據,從而改變界面。html

那麼假如咱們將二者結合一下,將 this.setData 封裝起來,豈不是能夠像開發 Vue 應用同樣地使用 this.foo = 'hello' 來開發小程序了?前端

  • 更進一步地,能夠實現 h5 和小程序 js 部分代碼的同構
  • 更進一步地,增長模板編譯和解析就能夠連 wxml/html 部分也同構
  • 更進一步地,兼容 RN/Weex/快應用
  • 更進一步地,世界大同,天下爲公,前端工程師所有失業...23333

否定三連.jpg

0.源碼地址

1.綁定簡單屬性

第一步咱們先定一個小目標:掙他一個億!!!java

對於簡單非嵌套屬性(非對象,數組),直接對其賦值就能改變界面。git

<!-- index.wxml -->
<view>msg: {{ msg }}</view>
<button bindtap="tapMsg">change msg</button>
// index.js
TuaPage({
    data () {
        return {
            msg: 'hello world',
        }
    },
    methods: {
        tapMsg () {
            this.msg = this.reverseStr(this.msg)
        },
        reverseStr (str) {
            return str.split('').reverse().join('')
        },
    },
})

這一步很簡單啦,直接對於 data 中的每一個屬性都綁定下 getter、setter,在 setter 中調用下 this.setData 就好啦。github

/**
 * 將 source 上的屬性代理到 target 上
 * @param {Object} source 被代理對象
 * @param {Object} target 被代理目標
 */
const proxyData = (source, target) => {
    Object.keys(source).forEach((key) => {
        Object.defineProperty(
            target,
            key,
            Object.getOwnPropertyDescriptor(source, key)
        )
    })
}

/**
 * 遍歷觀察 vm.data 中的全部屬性,並將其直接掛到 vm 上
 * @param {Page|Component} vm Page 或 Component 實例
 */
const bindData = (vm) => {
    const defineReactive = (obj, key, val) => {
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get () { return val },
            set (newVal) {
                if (newVal === val) return

                val = newVal
                vm.setData($data)
            },
        })
    }

    /**
     * 觀察對象
     * @param {any} obj 待觀察對象
     * @return {any} 已被觀察的對象
     */
    const observe = (obj) => {
        const observedObj = Object.create(null)

        Object.keys(obj).forEach((key) => {
            // 過濾 __wxWebviewId__ 等內部屬性
            if (/^__.*__$/.test(key)) return

            defineReactive(
                observedObj,
                key,
                obj[key]
            )
        })

        return observedObj
    }

    const $data = observe(vm.data)

    vm.$data = $data
    proxyData($data, vm)
}

/**
 * 適配 Vue 風格代碼,使其支持在小程序中運行(告別不方便的 setData)
 * @param {Object} args Page 參數
 */
export const TuaPage = (args = {}) => {
    const {
        data: rawData = {},
        methods = {},
        ...rest
    } = args

    const data = typeof rawData === 'function'
        ? rawData()
        : rawData

    Page({
        ...rest,
        ...methods,
        data,
        onLoad (...options) {
            bindData(this)

            rest.onLoad && rest.onLoad.apply(this, options)
        },
    })
}

2.綁定嵌套對象

那麼若是數據是嵌套的對象咋辦咧?

其實也很簡單,我們遞歸觀察一下就好。web

<!-- index.wxml -->
<view>a.b: {{ a.b }}</view>
<button bindtap="tapAB">change a.b</button>
// index.js
TuaPage({
    data () {
        return {
            a: { b: 'this is b' },
        }
    },
    methods: {
        tapAB () {
            this.a.b = this.reverseStr(this.a.b)
        },
        reverseStr (str) {
            return str.split('').reverse().join('')
        },
    },
})

observe -> observeDeep:在 observeDeep 中判斷是對象就遞歸觀察下去。編程

// ...

/**
 * 遞歸觀察對象
 * @param {any} obj 待觀察對象
 * @return {any} 已被觀察的對象
 */
const observeDeep = (obj) => {
    if (typeof obj === 'object') {
        const observedObj = Object.create(null)

        Object.keys(obj).forEach((key) => {
            if (/^__.*__$/.test(key)) return

            defineReactive(
                observedObj,
                key,
                // -> 注意在這裏遞歸
                observeDeep(obj[key]),
            )
        })

        return observedObj
    }

    // 簡單屬性直接返回
    return obj
}

// ...

3.劫持數組方法

你們都知道,Vue 劫持了一些數組方法。我們也來依葫蘆畫瓢地實現一下~小程序

/**
 * 劫持數組的方法
 * @param {Array} arr 原始數組
 * @return {Array} observedArray 被劫持方法後的數組
 */
const observeArray = (arr) => {
    const observedArray = arr.map(observeDeep)

    ;[
        'pop',
        'push',
        'sort',
        'shift',
        'splice',
        'unshift',
        'reverse',
    ].forEach((method) => {
        const original = observedArray[method]

        observedArray[method] = function (...args) {
            const result = original.apply(this, args)
            vm.setData($data)

            return result
        }
    })

    return observedArray
}
其實,Vue 還作了個優化,若是當前環境有 __proto__ 屬性,那麼就把以上方法直接加到數組的原型鏈上,而不是對每一個數組數據的方法進行修改。

4.實現 computed 功能

computed 功能平常還蠻經常使用的,經過已有的 data 元數據,派生出一些方便的新數據。segmentfault

要實現的話,由於 computed 中的數據都定義成函數,因此其實直接將其設置爲 getter 就行啦。

/**
 * 將 computed 中定義的新屬性掛到 vm 上
 * @param {Page|Component} vm Page 或 Component 實例
 * @param {Object} computed 計算屬性對象
 */
const bindComputed = (vm, computed) => {
    const $computed = Object.create(null)

    Object.keys(computed).forEach((key) => {
        Object.defineProperty($computed, key, {
            enumerable: true,
            configurable: true,
            get: computed[key].bind(vm),
            set () {},
        })
    })

    proxyData($computed, vm)

    // 掛到 $data 上,這樣在 data 中數據變化時能夠一塊兒被 setData
    proxyData($computed, vm.$data)

    // 初始化
    vm.setData($computed)
}

5.實現 watch 功能

接下來又是一個炒雞好用的 watch 功能,即監聽 datacomputed 中的數據,在其變化的時候調用回調函數,並傳入 newValoldVal

const defineReactive = (obj, key, val) => {
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get () { return val },
        set (newVal) {
            if (newVal === val) return

            // 這裏保存 oldVal
            const oldVal = val
            val = newVal
            vm.setData($data)

            // 實現 watch data 屬性
            const watchFn = watch[key]
            if (typeof watchFn === 'function') {
                watchFn.call(vm, newVal, oldVal)
            }
        },
    })
}

const bindComputed = (vm, computed, watch) => {
    const $computed = Object.create(null)

    Object.keys(computed).forEach((key) => {
        // 這裏保存 oldVal
        let oldVal = computed[key].call(vm)

        Object.defineProperty($computed, key, {
            enumerable: true,
            configurable: true,
            get () {
                const newVal = computed[key].call(vm)

                // 實現 watch computed 屬性
                const watchFn = watch[key]
                if (typeof watchFn === 'function' && newVal !== oldVal) {
                    watchFn.call(vm, newVal, oldVal)
                }

                // 重置 oldVal
                oldVal = newVal

                return newVal
            },
            set () {},
        })
    })

    // ...
}

看似不錯,實則否則。

我們如今碰到了一個問題:如何監聽相似 'a.b' 這樣的嵌套數據?

這個問題的緣由在於咱們在遞歸遍歷數據的時候沒有記錄下路徑。

6.記錄路徑

解決這個問題並不難,其實咱們只要在遞歸觀察的每一步中傳遞 key 便可,注意對於數組中的嵌套元素傳遞的是 [${index}]

而且一旦咱們知道了數據的路徑,還能夠進一步提升 setData 的性能。

由於咱們能夠精細地調用 vm.setData({ [prefix]: newVal }) 修改其中的部分數據,而不是將整個 $datasetData

const defineReactive = (obj, key, val, path) => {
    Object.defineProperty(obj, key, {
        // ...
        set (newVal) {
            // ...

            vm.setData({
                // 由於不知道依賴因此更新整個 computed
                ...vm.$computed,
                // 直接修改目標數據
                [path]: newVal,
            })

            // 經過路徑來找 watch 目標
            const watchFn = watch[path]
            if (typeof watchFn === 'function') {
                watchFn.call(vm, newVal, oldVal)
            }
        },
    })
}

const observeArray = (arr, path) => {
    const observedArray = arr.map(
        // 注意這裏的路徑拼接
        (item, idx) => observeDeep(item, `${path}[${idx}]`)
    )

    ;[
        'pop',
        'push',
        'sort',
        'shift',
        'splice',
        'unshift',
        'reverse',
    ].forEach((method) => {
        const original = observedArray[method]

        observedArray[method] = function (...args) {
            const result = original.apply(this, args)

            vm.setData({
                // 由於不知道依賴因此更新整個 computed
                ...vm.$computed,
                // 直接修改目標數據
                [path]: observedArray,
            })

            return result
        }
    })

    return observedArray
}

const observeDeep = (obj, prefix = '') => {
    if (Array.isArray(obj)) {
        return observeArray(obj, prefix)
    }

    if (typeof obj === 'object') {
        const observedObj = Object.create(null)

        Object.keys(obj).forEach((key) => {
            if (/^__.*__$/.test(key)) return

            const path = prefix === ''
                ? key
                : `${prefix}.${key}`

            defineReactive(
                observedObj,
                key,
                observeDeep(obj[key], path),
                path,
            )
        })

        return observedObj
    }

    return obj
}

/**
 * 將 computed 中定義的新屬性掛到 vm 上
 * @param {Page|Component} vm Page 或 Component 實例
 * @param {Object} computed 計算屬性對象
 * @param {Object} watch 偵聽器對象
 */
const bindComputed = (vm, computed, watch) => {
    // ...

    proxyData($computed, vm)

    // 掛在 vm 上,在 data 變化時從新 setData
    vm.$computed = $computed

    // 初始化
    vm.setData($computed)
}

7.異步 setData

目前的代碼還有個問題:每次對於 data 某個數據的修改都會觸發 setData,那麼假如反覆地修改同一個數據,就會頻繁地觸發 setData。而且每一次修改數據都會觸發 watch 的監聽...

而這偏偏是使用小程序 setData api 的大忌:

總結一下就是這三種常見的 setData 操做錯誤:

  1. 頻繁的去 setData
  2. 每次 setData 都傳遞大量新數據
  3. 後臺態頁面進行 setData

計將安出?

改代碼.png

答案就是緩存一下,異步執行 setData~

let newState = null

/**
 * 異步 setData 提升性能
 */
const asyncSetData = ({
    vm,
    newData,
    watchFn,
    prefix,
    oldVal,
}) => {
    newState = {
        ...newState,
        ...newData,
    }

    // TODO: Promise -> MutationObserve -> setTimeout
    Promise.resolve().then(() => {
        if (!newState) return

        vm.setData({
            // 由於不知道依賴因此更新整個 computed
            ...vm.$computed,
            ...newState,
        })

        if (typeof watchFn === 'function') {
            watchFn.call(vm, newState[prefix], oldVal)
        }

        newState = null
    })
}

在 Vue 中由於兼容性問題,優先選擇使用 Promise.then,其次是 MutationObserve,最後纔是 setTimeout

由於 Promise.thenMutationObserve 屬於 microtask,而 setTimeout 屬於 task

爲啥要用 microtask

根據 HTML Standard,在每一個 task 運行完之後,UI 都會重渲染,那麼在 microtask 中就完成數據更新,當前 task 結束就能夠獲得最新的 UI 了。反之若是新建一個 task 來作數據更新,那麼渲染就會進行兩次。(固然,瀏覽器實現有很多不一致的地方)

有興趣的話推薦看下這篇文章:Tasks, microtasks, queues and schedules

8.代碼重構

以前的代碼爲了方便地獲取 vm 和 watch,在 bindData 函數中又定義了三個函數,整個代碼耦合度過高了,函數依賴很不明確。

// 代碼耦合度過高
const bindData = (vm, watch) => {
    const defineReactive = () => {}
    const observeArray = () => {}
    const observeDeep = () => {}
    // ...
}

這樣在下一步編寫單元測試的時候很麻煩。

爲了寫測試讓我們來重構一把,利用學習過的函數式編程中的高階函數把依賴注入。

// 高階函數,傳遞 vm 和 watch 而後獲得 asyncSetData
const getAsyncSetData = (vm, watch) => ({ ... }) => { ... }

// 從 bindData 中移出來
// 原來放在裏面就是爲了獲取 vm,而後調用 vm.setData
// 以及經過 watch 獲取監聽函數
const defineReactive = ({
    // ...
    asyncSetData, // 不傳 vm 改爲傳遞 asyncSetData
}) => { ... }

// 同理
const observeArray = ({
    // ...
    asyncSetData, // 同理
}) => { ... }

// 一樣外移,由於依賴已注入了 asyncSetData
const getObserveDeep = (asyncSetData) => { ... }

// 函數外移後代碼邏輯更加清晰精簡
const bindData = (vm, observeDeep) => {
    const $data = observeDeep(vm.data)
    vm.$data = $data
    proxyData($data, vm)
}

高階函數是否是很膩害!代碼瞬間就在沒事的時候,在想的時候,到一個地方,不相同的地方,到這個地方,來了吧!能夠瞧一瞧,不同的地方,不相同的地方,改變了不少不少

不少不少

那麼接下來你必定會偷偷地問本身,這麼膩害的技術要去哪裏學呢?

那麼在哪裏才能買獲得呢

9.依賴收集

其實以上代碼還有一個目前解決不了的問題:咱們不知道 computed 裏定義的函數的依賴是什麼。因此在 data 數據更新的時候咱們只好所有再算一遍。

也就是說當 data 中的某個數據更新的時候,咱們並不知道它會影響哪一個 computed 中的屬性,特別的還有 computed 依賴於 computed 的狀況。

計將安出?

且聽下回分解~溜了溜了,嘿嘿嘿...

塘主.gif

以上 to be continued...

相關文章
相關標籤/搜索