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

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

چرا reactivity?

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

const price = 100
const fee = 10

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

render(total()) // 110

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

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

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

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

خروجی نهایی
خروجی نهایی

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

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

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

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

    methods: {
        sayHello() {
            console.log(&quothello world&quot)
        },
    },
})

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

const Observable = (input) => {
    const deps = {}
    let effect = null
    const _app = {
        ...input.data(),
    }
}
  • از _deps برای نگه داری وابستگی های متغیر ها به متد های computed استفاده میشه
  • از effect برای نگهداری 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 علت خاصی نداره فقط میخواستم نشون بدم از هر دو روش میشه این کار رو انجام داد. این کد به ما کمک میکنه، زمانی که کاربر یکی از متد های computed رو صدا زد، ما به اون فاکنشن دسترسی داشته باشیم

اضافه کردن 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(&quotapp&quot)
    appElem = `
    <table class=&quottable&quot>
            <tbody>
                <tr>
                    <td class=&quotbold&quot><p>product price</p></td>
                    <td class=&quotprice bold&quot >${App.price}$</td>
                    <td>
                        <div class=&quotbtn-group-vertical&quot role=&quotgroup&quot aria-label=&quotFirst group&quot>
                            <button type=&quotbutton&quot class=&quotbtn btn-secondary&quot @click=&quotprice_up&quot>+</button>
                            <button type=&quotbutton&quot class=&quotbtn btn-secondary&quot @click=&quotprice_down&quot>-</button>
                        </div>
                    </td>
                </tr>
                <tr>
                    <td class=&quotbold&quot>Fee</td>
                    <td class=&quotprice bold&quot>${App.fee}%</td>
                    <td>
                        <div class=&quotbtn-group-vertical&quot role=&quotgroup&quot aria-label=&quotFirst group&quot>
                            <button type=&quotbutton&quot class=&quotbtn btn-secondary&quot @click=&quotfee_up&quot>+</button>
                            <button type=&quotbutton&quot class=&quotbtn btn-secondary&quot @click=&quotfee_down&quot>-</button>
                        </div>
                    </td>
                </tr>
                <tr>
                    <td class=&quotbold&quot>Total</td>
                    <td class=&quotprice bold&quot>${App.totalSpend}$</td>
                    <td></td>
                </tr>
            </tbody>
        </table>
    `
}

render()

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

<td class=&quotprice bold&quot>${App.fee}%</td>

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

<button @click=&quotfee_up&quot>+</button> <button @click=&quotfee_down&quot>-</button>

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

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

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

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

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

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

همچنین میتونید این مطلب رو در بلاگ شخصی من بخونید

https://mediv.vercel.app/blog/data-reactivity-in-javascript


HAPPY CODING