بازگشت

پیاده سازی reactivity فریمورک ویو در جاواسکریپت

data reactivity in javascript

توی این آموزش قصد داریم تا با هم از صفر یک سیستم دیتا reactivity رو در جاواسکریپت پیاده سازی کنیم.

چرا reactivity?

براتون یه مثال میزنم، کد زیر رو در نظر بگیرید

const price = 100
const fee = 10

const total = () => {
    return price + fee
}

render(total()) // 110

// new value
price = 300
render(total()) // 310

همونطوری که میبینید اگر ما بخوایم بدون استفاده از reactivy یک مقدار رو آپدیت کنیم، باید این کار رو به صورت دستی انجام بدیم و در هر مرحله مثل کد بالا، متد های خودمون رو صدا بزنیم

اما reactiviy به ما کمک میکنه این پروسه رو اتوماتیک کنیم و اجازه بدیم روند برنامه صدا زدن متد ها و آپدیت داده های ما رو به عهده بگیره.

خروجی این آموزش به این صورت خواهد بود

data reactivity in vanilla javascript

ری اکتیویتی در ویو جی اس

تو این آموزش سعی میکنیم تا سیستم شبیه به فریم ورک vue رو پیاده سازی کنیم ( نسخه 2 )

ما میخوایم ورودی شبیه به کد زیر داشته باشیم ( درست مثل ویو )

const App = Observable({
    data() {
        return {
            price: 300,
            fee: 10,
        }
    },
    computed: {
        totalSpend() {
            return this.price + this.fee
        },
    },

    methods: {
        sayHello() {
            console.log("hello world")
        },
    },
})

خب برای شروع متد Observable رو تعریف میکنیم

const Observable = (input) => {
    const deps = {}
    let effect = null
    const _app = {
        ...input.data(),
    }
}

  • deps -> برای نگه داری وابستگی های متغیر ها به متد های computed استفاده میشه
  • effects -> برای نگهداری computed فعال و در حال اجرا استفاده میشه
  • _app -> ورودی اپ ما اینجاد که به صورت دیفالت متغیر های داخل آبجکت Data رو داخلش میریزیم

برای ری اکتیو کردن داده ها، در جاوااسکریپت API وجود داره به اسمProxy. این API به شما اجازه میده درخواست های ارسالی به آبجکت رو شخصی سازی کنید. ما از این API برای خوندن و نوشتن داده توی متغیر هامون استفاده میکنیم

ما به 2 تا Proxy نیاز خواهیم داشت. یکی برای گوش کردن به تغییرات دیتا در ابجکت _app و دیگری برای گوش کردن به تغییرات computed ها

ابتدا با دیتا شروع میکنیم:

const Observable = (input) => {
    const deps = {}
    let effect = null
    const _app = {
        ...input.data(),
    }

    // base proxy
    const _appProxy = new Proxy(_app, {
        get(target, key) {
            return target[key]
        },
        set(target, key, value) {
            target[key] = value
            return true
        },
    })

    return _appProxy
}

در مرحله بعد Method هامون رو به آبجکت _app اضافه میکنیم

// init methods
_app.methods = {}
Object.keys(input.methods).forEach((key) => {
    _app.methods[key] = input.methods[key].bind(_app)
})

تو اینجا از input.methods[key].bind(_app) استفاده شده. برای زمانی هست که وقتی از this استفاده کردیم رفرنس رو به ابجکت _app از دست ندیم

تو مرحله بعد computed هامون رو اضافه میکنیم

// init computed -- convert to getters
Object.keys(input.computed).forEach((key) => {
    Object.defineProperty(_appProxy, key, {
        get() {
            const res = effect.call(_appProxy)
            return res
        },
    })
})

توی اینجا به جای Proxy از DefineProperty علت خاصی نداره فقط میخواستم نشون بدم از هر دو روش میشه این کار رو انجام داد


اضافه کردن reactivity به computed ها


ما نیاز به مکانیزمی داریم تا بتونیم تشخیص بدیم که computed های ما چه متغیر هایی رو اجرا میکنن و بعد از تشخیص این computed ها رو به عنوان dependency به متغیر هامون اضافه میکنیم تا در صورت تغییر مقدار هر کدوم از متغیر ها، computed هامون رو دوباره اجرا بکنیم

قطعه کد زیر رو نظر بگیرید

const App = Observable({
    data() {
        return {
            price: 300,
        }
    },
    computed: {
        getPrice() {
            return this.price
        },
    },
})

App.getPrice // 300

اگر کد بالا رو اجرا بکنیم، Getter ای که در داخل فاکنشن Obseravable تعریف کردیم اجرا میشه. ما میدونیم که توی جاوااسکریپت در هر لحظه فقط یک دستور میتونه اجرا بشه پس: وقتی که getPrice اجرا میشه، ما باید اون رو به عنوان effect درحال اجرا در نظر بگیریم

پس کد بخش computed ما به این صورت تغییر میکنه:

// init computed -- convert computeds to getters
Object.keys(input.computed).forEach((key) => {
    Object.defineProperty(_appProxy, key, {
        get() {
            effect = input.computed[key]
            const res = effect.call(_appProxy)
            effect = null
            return res
        },
    })
})

قبل از اجرا شدن متد computed ما، اون رو در درون متغیر effect قرار میدیم و بعد از اتمام اجرا، اون رو به null تغییر میدیم

به این نکته توجه کنید وقتی که computed ما اجرا میشه effect.call(_appProxy) چون در داخل این متد از this.price استفاده کردیم، این باعث میشه Proxy ما برای دیتا اجرا بشه

از اونجایی که ما effect و متد فعال فعلی رو ذخیره داریم، میتونیم این متد رو به عنوان dependency به متغیر ها اضافه کنیم

پس بخش get ما به این صورت تغییر میکنه

// base proxy
const _appProxy = new Proxy(_app, {
    get(target, key) {
        if (!deps[key]) {
            deps[key] = new Set()
        }
        effect && deps[key].add(effect)
        return target[key]
    },
    // --------------
})

ابتدا چک میکنم تا ببینیم متغیر فعلی دارای در داخل ابجکت deps وجود داره یا نه، اگر وجود نداشت اون رو ایجاد میکنیم. علت استفاده از new Set() به این خاطر هست که ما نمیخوایم یک فاکنشن چند بار اجرا بشه

بعد از ذخیره deps ها ما همچین ساختاری رو خواهیم داشت

console.log(deps)
/*  {
        price:  getPrice() { return this.price; }
    }
*/

حالا اگر هرکدوم از این دیتا های ما تغییر بکنند، ما dependency های اون را دونه به دونه صدا میزنیم

برای اینکه متوجه بشیم آیا تغییر کردنند یا نه از set در proxy استفاده میکنیم کد ما به این صورت تغییر میکنه:

// base proxy
const _appProxy = new Proxy(_app, {
    // ----------------
    set(target, key, value) {
        target[key] = value
        const _deps = deps[key]
        if (_deps) {
            _deps.forEach((effect) => {
                return effect.call(_appProxy)
            })
        }
        return true
    },
})

همونطور که میبینید

const _deps = deps[key]
if (_deps) {
    _deps.forEach((effect) => {
        return effect.call(_appProxy)
    })
}

همه ی deps ها رو اجرا میکنیم تا مطلع بشن که دیتای ما تغییر کرده

اگر تا اینجا دنبال کرده باشید کد ما به این صورت خواهد بود

const Observable = (input) => {
    const deps = {}
    let effect = null
    const _app = {
        ...input.data(),
    }

    // base proxy
    const _appProxy = new Proxy(_app, {
        get(target, key) {
            if (!deps[key]) {
                deps[key] = new Set()
            }
            effect && deps[key].add(effect)
            return target[key]
        },
        set(target, key, value) {
            target[key] = value
            const _deps = deps[key]
            if (_deps) {
                _deps.forEach((effect) => {
                    return effect.call(_appProxy)
                })
            }
            return true
        },
    })

    // computed proxy
    // init methods
    _app.methods = {}
    Object.keys(input.methods).forEach((key) => {
        _app.methods[key] = input.methods[key].bind(_app)
    })

    // init computed -- convert computeds to getters
    Object.keys(input.computed).forEach((key) => {
        Object.defineProperty(_appProxy, key, {
            get() {
                effect = input.computed[key]
                const res = effect.call(_appProxy)
                effect = null
                return res
            },
        })
    })
    return _appProxy
}

خب کار ما اینجا تموم میشه. برای اینکه ببینیم کدی که زدیم کار میکنه یا نه یه پروژه کوچیک با هم انجام میدیم

یک ابجکت از کلاس Observable مون میسازیم با ورودی های زیر:

const App = Observable({
    data() {
        return {
            price: 300,
            fee: 10,
        }
    },
    computed: {
        totalSpend() {
            return this.price + this.fee
        },
    },

    methods: {
        price_up() {
            this.price++
        },
        price_down() {
            this.price--
        },

        fee_up() {
            this.fee++
        },
        fee_down() {
            this.fee--
        },
    },
})

یک فاکنشن تعریف میکنیم به اسم render وظیفه این فاکنشن اضافه کردن کد html یک جدول به صفحه ماست (مثل گیف پروژه نهایی در بتدای پروژه)

const render = () => {
    const appElem = document.getElementById("app")
    appElem.innerHTML = `
    <table class="table">
            <tbody>
                <tr>
                    <td class="bold"><p>product price</p></td>
                    <td class="price bold" >${App.price}$</td>
                    <td>
                        <div class="btn-group-vertical" role="group" aria-label="First group">
                            <button type="button" class="btn btn-secondary" @click="price_up">+</button>
                            <button type="button" class="btn btn-secondary" @click="price_down">-</button>
                        </div>
                    </td>
                </tr>
                <tr>
                    <td class="bold">Fee</td>
                    <td class="price bold">${App.fee}%</td>
                    <td>
                        <div class="btn-group-vertical" role="group" aria-label="First group">
                            <button type="button" class="btn btn-secondary" @click="fee_up">+</button>
                            <button type="button" class="btn btn-secondary" @click="fee_down">-</button>
                        </div>
                    </td>
                </tr>
                <tr>
                    <td class="bold">Total</td>
                    <td class="price bold">${App.totalSpend}$</td>
                    <td></td>
                </tr>
            </tbody>
        </table>
    `
}

render()

همونطور که میبینید در داخل جدول از String Template ها استفاده شده و داده هامون رو قراره نشون بده

<td class="price bold">${App.fee}%</td>

همچنین مثل فریمورک vue از custom attribute ها استفاده کردیم تا بتونیم به click event گوش کنیم

<button @click="fee_up">+</button> <button @click="fee_down">-</button>

خب حالا به اونت کلیک روی صفحه گوش میدیم و برسی میکنیم که کاربر کجا کلیک کرده اگر روی المنت هایی که click attribute دارن کلیک کرده باشه، فاکنشن ها رو اجرا میکنیم

document.addEventListener("click", (e) => {
    const customClickAttr = e.target.attributes
    // get click attribute from list of attributes
    const clickAttr = customClickAttr.getNamedItem("@click")
    if (clickAttr) {
        const methodName = clickAttr.value
        App.methods[methodName]()
        render()
    }
})

بعد از اینکه دیتامون رو اپدیت کردیم نیاز هست تا صفحه رو هم آپدیت کنیم. به خاطر همین بعد از اجرا متد هامون، متد render رو دوباره صدا میزنیم



خب پروژه ما اینجا تموم میشه، برای تمرین اگر دوست داشتید سعی کنید مثل Vue قابلیت Watcher و گوش کردن به همه تغییرات متغیر ها رو پیاده کنید.

اگر این آموزش براتون جالب بود میتونید کد های این پروژه روی گیت هاب مشاهده کنید

[سورس کد پروژه]