مصطفی میری
مصطفی میری
خواندن ۱۴ دقیقه·۲ سال پیش

بررسی عمیق OnPush Change Detection در Angular

فریم ورک Angular دو استراتژی را پیاده سازی می کند که رفتار change detection را در سطح کامپوننت ها کنترل می کند. این استراتژی ها به صورت زیر تعریف می شوند :Default - OnPush

Export enum ChangeDetectionStrategy { OnPush = 0 , Default = 1 }

فریم ورک Angular از این استراتژی‌ها برای تعیین اینکه آیا کامپوننت فرزند باید هنگام اجرای change detection برای کامپوننت والد بررسی شود یا خیر، استفاده می‌کند . استراتژی تعریف شده برای یک کامپوننت بر همه directiveهای فرزند تأثیر می گذارد زیرا آنها به عنوان بخشی از host component بررسی می شوند. یک استراتژی تعریف شده را نمی توان در زمان اجرا لغو کرد.

استراتژی default، که بعنوانCheckAlwaysبه آن اشاره می‌شود ، change detection خودکار برای یک کامپوننت است، مگر اینکه view مشخصا جدا شده باشد. آنچه به عنوانOnPushاستراتژی شناخته می شود، به این معنی است که اگر یک کامپوننت به عنوان dirty علامت گذاری شود، change detection برای آن اجرا می شود . Angular مکانیسم هایی را برای علامت گذاری خودکار یک کامپوننت به عنوان dirty پیاده سازی می کند. در صورت نیاز، یک کامپوننت را می توان به صورت دستی با استفاده از متد markForCheck که در ChangeDetectorRef قرار دارد ، علامت گذاری کرد .

وقتی که این استراتژی را با استفاده از دکوراتور ()Component@تعریف می کنیم، کامپایلر Angular آن را در تعریف یک کامپوننت از طریق تابع defineComponent ثبت می کند . به عنوان مثال، برای کامپوننتی مانند زیر:

@Component({ selector: 'a-op', template: `I am OnPush component`, changeDetection: ChangeDetectionStrategy.OnPush }) export class AOpComponent {}

کد تولید شده توسط کامپایلر به صورت زیر است:

وقتی Angular یک کامپوننت را نمونه‌سازی می‌کند، از این تعریف برای تنظیم یک flag بر روی نمونه ساخته شده ازLView استفاده می‌کند که view کامپوننت را نشان می‌دهد:

به این معنی است که همه نمونه‌هایLViewایجاد شده برای این کامپوننت دارای یکی از دو پرچم CheckAlways یا Dirty هستند. برای استراتژیOnPush، پرچمDirtyبه طور خودکار پس از اولین مرحله change detection تنظیم نمی شود.

هنگامی که Angular تعیین می کند که آیا یک کامپوننت باید بررسی شود ، پرچم های تنظیم شده رویLViewدر داخل تابع refreshView بررسی می شوند :

function refreshComponent(hostLView, componentHostIdx) { // Only attached components that are CheckAlways or OnPush and dirty // should be refreshed if (viewAttachedToChangeDetector(componentView)) { const tView = componentView[TVIEW]; if (componentView[FLAGS] & (LViewFlags.CheckAlways | LViewFlags.Dirty)) { refreshView(tView, componentView, tView.template, componentView[CONTEXT]); } else if (componentView[TRANSPLANTED_VIEWS_TO_REFRESH] > 0) { // Only attached components that are CheckAlways // or OnPush and dirty should be refreshed refreshContainsDirtyView(componentView); } } }

بیایید اکنون این استراتژی ها را با جزئیات بیشتر بررسی کنیم.

استراتژی default

فرایند change detection در استراتژی default زمانی اجرا می‌شود که:

  1. یک Input@ آپدیت شود.
  2. یک Browser event مثل click, change, keyup و ... رخ دهد.
  3. یک setTimeout یا setInterval a در حال اجرا باشد.
  4. درخواست های async : مثل XHR و promises که اجرا شود.

استراتژی default change detection به این معنی است که اگر کامپوننت والد بررسی شده باشد، کامپوننت فرزند همیشه بررسی می‌شود . تنها استثنای این قانون این است که change detector کامپوننت فرزند را مانند زیر جدا کنید:

@Component({ selector: 'a-op', template: `I am OnPush component` }) export class AOpComponent { constructor(private cdRef: ChangeDetectorRef) { cdRef.detach(); } }

توجه داشته باشید که اگر کامپوننت والد بررسی نشده باشد، انگولار change detection را برای کامپوننت فرزند اجرا نمی‌کند، حتی اگر از استراتژی default change detection استفاده کند. این از این واقعیت ناشی می شود که Angular این مکانیسم را برای کامپوننت فرزند به عنوان بخشی از بررسی والد آن اجرا می کند .

فریم ورک Angular هیچ workflow را بر روی دولوپرها اعمال نمی کند تا تشخیص دهد که چه زمانی وضعیت یک کامپوننت تغییر می کند، به همین دلیل رفتار پیش فرض این است که همیشه کامپوننت ها را بررسی کند. یک مثال از workflow ، انتقال یک شی تغییرناپذیر از طریقInput@است. این چیزی است که برای استراتژیOnPushاستفاده می شود و در ادامه آن را بررسی خواهیم کرد.

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

@Component({ selector: 'a-op', template: ` <button (click)=&quotchangeName()&quot>Change name</button> <b-op [user]=&quotuser&quot></b-op> `, }) export class AOpComponent { user = { name: 'A' }; changeName() { this.user.name = 'B'; } } @Component({ selector: 'b-op', template: `<span>User name: {{user.name}}</span>`, }) export class BOpComponent { @Input() user; }

وقتی روی دکمه کلیک می کنیم، Angular یک event handler را اجرا می کند که در آن ما user.nameرا بروز رسانی می کنیم . به عنوان بخشی از اجرای حلقه change detection بعدی، کامپوننتB فرزند بررسی می شود و صفحه به روز می شود:

در حالی که ارجاع به شیuserتغییر نکرده است، در داخل آن تغییراتی داشته است، اما همچنان می‌توانیم نام جدید را روی صفحه نمایش ببینیم. به همین دلیل است که رفتار پیش فرض این است که همه کامپوننت ها را بررسی کند. بدون محدودیت object immutability در Angular، نمی‌توان متوجه تغییر ورودی‌ها و به‌روزرسانی وضعیت کامپوننت شد.

استراتژی OnPush

فرایند change detection در استراتژی default زمانی اجرا می‌شود که:

  1. مرجع ورودی Input@ تغییر کند.
  2. یک Browser event مثل click, change, keyup و ... رخ دهد.

در حالی که Angular محدودیت object immutability را به ما تحمیل نمی کند، اما مکانیزمی به ما می دهد تا یک کامپوننت را دارای ورودی های تغییرناپذیر اعلام کنیم تا تعداد دفعات بررسی کامپوننت را کاهش دهیم. این مکانیزم استراتژی OnPushchange detectionاست و یک تکنیک بهینه سازی بسیار رایج است. در درون کدها این استراتژیCheckOnceنامیده می شود ، زیرا به این معنی است که از اجرای change detection برای یک کامپوننت صرفنظر می شود تا زمانی که به عنوان dirty علامت گذاری شود، سپس یک بار بررسی می شود و سپس دوباره رد می شود. یک کامپوننت را می توان به صورت خودکار یا دستی با استفاده از متدmarkForCheckعلامت گذاری dirty کرد.

بیایید از مثال بالا استفاده کنیم و استراتژی OnPush change detectionرا برای کامپوننتB اعلام کنیم:

@Component({...}) export class AOpComponent { user = { name: 'A' }; changeName() { this.user.name = 'B'; } } @Component({ selector: 'b-op', template: ` <span>User name: {{user.name}}</span> `, changeDetection: ChangeDetectionStrategy.OnPush }) export class BOpComponent { @Input() user; }

وقتی برنامه را اجرا می کنیم Angular دیگر تغییری در user.name ایجاد نمی کند :

می‌توانید ببینید که کامپوننتBهنوز یک بار در طول بوت استرپ بررسی می‌شود - مقدار اولیهAرا ارائه می‌کند . اما در طول اجراهای بعدی change detection بررسی نمی‌شود، بنابراین با کلیک روی دکمه، تغییر نام از Aبه بهBرا مشاهده نمی‌کنید. این به این دلیل اتفاق می‌افتد که ارجاع به شیuserکه از طریقInput@ به کامپوننتBمنتقل می‌شود، تغییر نکرده است.

قبل از اینکه نگاهی به راه‌های مختلف علامت‌گذاری یک کامپوننت به عنوان dirty بیندازیم، در اینجا فهرستی از سناریوهای مختلفی وجود دارد که Angular برای تست رفتار OnPushاستفاده می‌کند:

باید کامپوننت های OnPush را در هنگام به‌روزرسانی وقتی dirty نیستند، نادیده گرفت.
نباید کامپوننت های OnPush را در هنگام به‌روزرسانی وقتی که رویدادهای والد رخ می‌دهند ، بررسی کنید.
باید کامپوننت های OnPush را در هنگام مقداردهی اولیه بررسی کنید.
باید doCheck را فراخوانی کنید، حتی زمانی که کامپوننت های OnPush ما dirty نیستند.
باید کامپوننت های OnPush را موقع به‌روزرسانی بررسی کنید وقتی inputها تغییر می‌کنند.
باید کامپوننت های OnPush را موقع به روز رسانی بررسی کنید هنگامی که رویدادهای کامپوننت رخ می دهد.
باید کامپوننت های OnPush را موقع بروز رسانی بررسی کنید هنگامی که رویدادهای فرزند رخ می دهد.
باید کامپوننت های OnPush والد را بررسی کنید هنگامی که یک دایرکتیو فرزند در یک template یک ایونت را منتشر می کند.

آخرین دسته از سناریوهای تستی نشان میدهد که فرآیند خودکار علامت‌گذاری یک کامپوننت dirty در سناریوهای زیر رخ می‌دهد:

  • یک Input@ تغییر می کند.
  • یک رویداد دریافت می شود که بر روی خود کامپوننت راه اندازی می شود.

حال بیایید اینها را بررسی کنیم.

ورودی با Input bindings@

در بیشتر موقعیت‌ها، تنها زمانی که ورودی‌های آن کامپوننت تغییر می‌کند، باید کامپوننت فرزند را بررسی کنیم. این امر مخصوصاً در مورد کامپوننت های pure presentational که ورودی آنها صرفاً از طریق اتصالات می‌آید صادق است.

بیایید مثال قبلی را در نظر بگیریم:

@Component({...}) export class AOpComponent { user = { name: 'A' }; changeName() { this.user.name = 'B'; } } @Component({ selector: 'b-op', template: ` <span>User name: {{user.name}}</span> `, changeDetection: ChangeDetectionStrategy.OnPush }) export class BOpComponent { @Input() user; }

همانطور که در بالا دیدیم، وقتی روی دکمه کلیک می کنیم و نام را تغییر می دهیم، نام جدید روی صفحه به روز نمی شود. دلیل آن این است که Angular مقایسه سطحی را برای پارامترهای ورودی انجام می دهد و ارجاع به شیuserتغییر نکرده است. تغییر مستقیم یک خاصیت شی منجر به ایجاد یک مرجع جدید نمی شود و به طور خودکار کامپوننت را علامت گذاری dirty نمی کند.

ما باید مرجع شیuserرا برای Angular تغییر دهیم تا تفاوتInput@را تشخیص دهد.اگر به جای تغییر در نمونه موجود، یک نمونه جدید ایجاد کنیم ، همه چیز همانطور که انتظار می رود کار خواهد کرد:

@Component({...}) export class AOpComponent { user = { name: 'A' }; changeName() { this.user = { ...this.user, name: 'B', } } }

بله، همه چیز خوب است:

به دلیل اینکه با تغییر خاصیت شی این کامپوننت بررسی نمی شود،‌ با اجرای Object.freeze می توانید به راحتی تغییر ناپذیری را بر روی اشیاء اعمال کنید :

export function deepFreeze(object) { const propNames = Object.getOwnPropertyNames(object); for (const name of propNames) { const value = object[name]; if (value && typeof value === 'object') { deepFreeze(value); } } return Object.freeze(object); }

به طوری که وقتی کسی سعی می کند شی را تغییر دهد، این خطا را می دهد:

احتمالاً بهترین روش استفاده از یک کتابخانه تخصصی مانند immer است :
import { produce } from 'immer'; @Component({...}) export class AOpComponent { user = { name: 'A' }; changeName() { this.user = produce(this.user, (draft) => { draft.name = 'B'; }); } }

این نیز به خوبی کار خواهد کرد.

رویدادهای رابط کاربری

همه رویدادهای native، هنگامی که بر روی کامپوننت فعلی اجرا می شوند، همه اجداد کامپوننت تا کامپوننت ریشه را dirty می کنند. پیشفرض بر این است که یک رویداد می تواند باعث تغییر در درخت کامپوننت ها شود. Angular نمی داند که آیا والدین تغییر خواهند کرده اند. به همین دلیل است که Angular همیشه هر کامپوننت اجدادی را پس از اجرا شدن یک رویداد بررسی می کند.

تصور کنید که یک سلسله مراتب درخت کامپوننت ها مانند کامپوننت هایOnPushدارید :

AppComponent HeaderComponent ContentComponent TodoListComponent TodoComponent

اگر یک event listener را در داخل TodoComponenttemplateضمیمه کنیم :

@Component({ selector: 'todo', template: ` <button (click)=&quotedit()&quot>Edit todo</button> `, changeDetection: ChangeDetectionStrategy.OnPush }) export class TodoComponent { edit() {} }

فریم ورک Angular قبل از اجرای event handler، همه کامپوننت های اجداد این کامپوننت را dirty می کند:

از این رو سلسله مراتب کامپوننت ها به صورت زیر می شود :

Root Component -> LViewFlags.Dirty | ... | ContentComponent -> LViewFlags.Dirty | | TodoListComponent -> LViewFlags.Dirty | | TodoComponent (event triggered here) -> markViewDirty() -> LViewFlags.Dirty

در طول چرخه change detection بعدی، Angular کل درخت کامپوننت های اجدادیTodoComponentرا بررسی می کند:

AppComponent (checked) HeaderComponent ContentComponent (checked) TodosComponent (checked) TodoComponent (checked)

توجه داشته باشید که HeaderComponentبه دلیل اینکه از اجداد TodoComponent نیست بررسی نشده است .

علامت گذاری دستی کامپوننت ها به عنوان dirty

بیایید به مثالی برگردیم که در آن هنگام به‌روزرسانی نام، ارجاع به شیuserرا تغییر دادیم. این کار به Angular امکان می‌دهد تا تغییرات را دریافت کرده و به‌طور خودکار کامپوننتBرا به عنوان dirty علامت‌گذاری کند. فرض کنید می خواهیم نام را به روز کنیم اما نمی خواهیم مرجع را تغییر دهیم. در این صورت، می توانیم به صورت دستی کامپوننت را به عنوان dirty علامت گذاری کنیم.

برای این کار می توانیم changeDetectorRefراتزریق کرده و از متدmarkForCheck آن استفاده کنیم تا برای Angular نشان دهیم که این کامپوننت باید بررسی شود:

@Component({...}) export class BOpComponent { @Input() user; constructor(private cd: ChangeDetectorRef) {} someMethodWhichDetectsAndUpdate() { this.cd.markForCheck(); } }

کجا می توانیم از someMethodWhichDetectsAndUpdateدر کد بالا استفاده کنیم ؟ هوک NgDoCheckکاندیدای بسیار خوبی است.این هوک قبل از اینکه Angular مکانیزم change detection را برای این کامپوننت اجرا کند و در حین بررسی کامپوننت والد اجرا می شود. اینجا جایی است که کد مقایسه مقادیر و همینطور علامت گذاری دستی کامپوننت به عنوان dirty هنگام change detection را قرار می دهیم.

اجرای این کار درNgDoCheckحتی اگر یک کامپوننت OnPushباشد اغلب باعث سردرگمی می شود. اما این عمدی است و اگر بدانید که به عنوان بخشی از بررسی کامپوننت والد اجرا می شود، تناقضی وجود ندارد. به خاطر داشته باشید که ngDoCheckفقط برای بالاترین کامپوننت فرزند فعال می شود. اگر کامپوننت دارای تعدادی فرزند باشد و Angular این کامپوننت را بررسی نکند، ngDoCheckبرای آنها فعال نمی شود.

ازngDoCheckبرای log کردن مراحل بررسی کامپوننت استفاده نکنید . درعوض، از تابع Accessor در داخل template مانند این استفاده کنید {{ ()logCheck }}.

بنابراین بیایید منطق مقایسه خود را در داخل NgDoCheck معرفی کنیم و وقتی تغییر را تشخیص دادیم، کامپوننت را dirty علامت گذاری کنیم:

@Component({...}) export class AOpComponent {...} @Component({ selector: 'b-op', template: ` <span>User name: {{user.name}}</span> `, changeDetection: ChangeDetectionStrategy.OnPush }) export class BOpComponent { @Input() user; previousUserName = ''; constructor(private cd: ChangeDetectorRef) {} ngDoCheck() { if (this.user.name !== this.previousUserName) { this.cd.markForCheck(); this.previousUserName = this.user.name; } } }

به یاد داشته باشید کهmarkForCheck تضمینی برای اجرای change detection نیست. برای جزئیات بیشتر به بخش manual control مراجعه کنید.

نقش Observablesها به عنوان Inputs@

حالا بیایید مثال خود را کمی پیچیده تر کنیم. بیایید فرض کنیم کامپوننت فرزندB ما بر اساس RxJ قابل مشاهده است که به‌روزرسانی‌ها را به صورت ناهمزمان منتشر می‌کند. این مشابه چیزی است که ممکن است در معماری مبتنی بر NgRx داشته باشید:

@Component({ selector: 'a-op', template: ` <button (click)=&quotchangeName()&quot>Change name</button> <b-op [user$]=&quotuser$.asObservable()&quot></b-op> `, }) export class AOpComponent { user$ = new BehaviorSubject({ name: 'A' }); changeName() { const user = this.user$.getValue(); this.user$.next( produce(user, (draft) => { draft.name = 'B'; }) ); } }

بنابراین ما این جریان از شیuser را در کامپوننت فرزندB دریافت می کنیم . ما باید subscribe کنیم، بررسی کنیم که آیا مقدار به روز شده است یا خیر و در صورت نیاز کامپوننت را به عنوان dirty علامت گذاری کنیم:

@Component({ selector: 'b-op', template: ` <span>User name: {{user.name}}</span> `, changeDetection: ChangeDetectionStrategy.OnPush }) export class BOpComponent { @Input() user$; user = null; constructor(private cd: ChangeDetectorRef) {} ngOnChanges() { this.user$.subscribe((user) => { if (user !== this.user) { this.cd.markForCheck(); this.user = user; } }); } }

منطق داخل ngOnChangesتقریباً همان کاری است که async pipe انجام می دهد :

export class AsyncPipe { transform() { if (obj) { this._subscribe(obj); } } private _updateLatestValue(async, value) { if (async === this._obj) { this._latestValue = value; this._ref!.markForCheck(); } } }

به همین دلیل است که رویکرد متداول این است که منطق subscription و مقایسه را به async pipe واگذار کنیم . تنها محدودیت این است که اشیا باید تغییر ناپذیر باشند.

در اینجا اجرای کامپوننتB که از async pipe استفاده می کند، آمده است:

@Component({ selector: 'b-op', template: ` <span>User name: {{(user$ | async).name}}</span> `, changeDetection: ChangeDetectionStrategy.OnPush }) export class BOpComponent { @Input() user$; }

مجموعه ای از test casesها وجود دارد که async pipe و تعامل آن با انواع مختلف را آزمایش می کنند:

describe('Observable', () => { describe('transform', () => { it('should return null when subscribing to an observable'); it('should return the latest available value'); it('should return same value when nothing has changed since the last call'); it('should dispose of the existing subscription when subscribing to a new observable'); it('should request a change detection check upon receiving a new value'); it('should return value for unchanged NaN'); }); }); describe('Promise', () => {...}); describe('null', () => {...}); describe('undefined', () => {...}); describe('other types', () => {...});

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

it('should request a change detection check upon receiving a new value', done => { pipe.transform(subscribable); emitter.emit(message); setTimeout(() => { expect(ref.markForCheck).toHaveBeenCalled(); done(); }, 10); });

در اینجا pipe به observable درون transformمتصل شده است و هنگامی که observable یک messageجدید منتشر می کند ، کامپوننت را به عنوان dirty علامت گذاری می کند.



این مقاله برگردان شده یک مقاله معتبر درباره این موضوع است.برای دسترسی به مقاله منبع اینجا کلیک کنید.

برای مشاهده پست های بیشتر و ارتباط با من از طریق لینکدین اینجا کلیک کنید.

امیدوارم براتون مفید واقع شده باشه.

angulardeep learning
Angular Developer
شاید از این پست‌ها خوشتان بیاید