توی این آموزش قصد داریم تا با هم از صفر یک سیستم دیتا reactivity رو در جاواسکریپت پیاده سازی کنیم.
براتون یه مثال میزنم، کد زیر رو در نظر بگیرید
const price = 100
const fee = 10
const total = () => {
return price + fee
}
render(total()) // 110
// new value
price = 300
render(total()) // 310
همونطوری که میبینید اگر ما بخوایم بدون استفاده از reactivy یک مقدار رو آپدیت کنیم، باید این کار رو به صورت دستی انجام بدیم و در هر مرحله مثل کد بالا، متد های خودمون رو صدا بزنیم
اما reactiviy به ما کمک میکنه این پروسه رو اتوماتیک کنیم و اجازه بدیم روند برنامه صدا زدن متد ها و آپدیت داده های ما رو به عهده بگیره.
خروجی این آموزش به این صورت خواهد بود
تو این آموزش سعی میکنیم تا سیستم شبیه به فریم ورک 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(),
}
}
ما به 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 های ما چه متغیر هایی رو اجرا میکنن و بعد از تشخیص این 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 رو دوباره صدا میزنیم