حقن التبعية الهرمية في React و MobX State Tree كنموذج مجال

بعد بعض المشاريع على React ، أتيحت لي الفرصة للعمل على تطبيق ما بموجب Angular 2. بصراحة ، لم أكن معجبًا. ولكن تم تذكر شيء واحد - إدارة منطق وحالة التطبيق باستخدام Dependency Injection. وتساءلت عما إذا كان من المناسب إدارة الحالة في React باستخدام DDD ، بنية ذات طبقات ، وحقن التبعية؟


إذا كنت مهتمًا بكيفية القيام بذلك ، والأهم من ذلك ، لماذا - مرحبًا بك في الخفض!


أن نكون صادقين ، حتى في الخلفية ، نادراً ما تستخدم شركة DI على أكمل وجه. ما لم يكن في التطبيقات الكبيرة حقا. في الشركات الصغيرة والمتوسطة ، حتى مع وجود واجهة DI ، يكون لكل واجهة عادة تطبيق واحد فقط. ولكن حقن التبعية لا يزال له مزايا:


  • الرمز منظم بشكل أفضل ، وتعمل الواجهات كعقود واضحة.
  • تبسيط إنشاء بذرة في اختبارات الوحدة.

لكن مكتبات الاختبار الحديثة لـ JS ، مثل Jest ، تسمح لك بكتابة moki ببساطة بناءً على نظام ES6 المعياري. حتى هنا لن نحقق الكثير من الأرباح من شركة DI.


تبقى النقطة الثانية - إدارة نطاق وعمر الكائنات. على الخادم ، عادةً ما يكون العمر مرتبطًا بالتطبيق بأكمله (Singleton) أو بالطلب. وعلى العميل ، الوحدة الرئيسية للكود هي المكون. سوف نعلق عليه.


إذا كنا بحاجة إلى استخدام الحالة على مستوى التطبيق ، فإن أسهل طريقة هي تعيين المتغير على مستوى وحدة ES6 واستيراده عند الضرورة. وإذا كانت الحالة مطلوبة فقط داخل المكون - this.state في هذه this.state . لكل شيء آخر ، هناك Context . لكن Context منخفض جدًا:


  • لا يمكننا استخدام السياق خارج شجرة مكون React. على سبيل المثال ، في طبقة منطق الأعمال.
  • لا يمكننا استخدام أكثر من سياق في Class.contextType . لتحديد الاعتماد على العديد من الخدمات المختلفة ، سيتعين علينا بناء "هرم الرعب" بطريقة جديدة:



الجديد useContext() Hook بتصحيح الموقف للمكونات الوظيفية قليلاً. لكننا لن نتخلص من العديد من <Context.Provider> . حتى نحول سياقنا إلى محدد موقع الخدمة ، والمكون الأصلي له إلى جذر التكوين. ولكن هنا ليس بعيدًا عن DI ، لذلك دعونا نبدأ!


يمكنك تخطي هذا الجزء والانتقال مباشرة إلى وصف البنية

تنفيذ آلية DI


أولاً نحتاج إلى سياق التفاعل:


 export const InjectorContext= React.createContext(null); 

نظرًا لأن React يستخدم مُنشئ المكون لتلبية احتياجاته ، فسنستخدم خاصية حقن العقار. للقيام بذلك ، حدد @inject decorator ، والذي:


  • يعين الخاصية Class.contextType ،
  • يحصل على نوع التبعية
  • يجد كائن Injector ويحل التبعية.

inject.js
 import "reflect-metadata"; export function inject(target, key) { //  static cotextType target.constructor.contextType = InjectorContext; //    const type = Reflect.getMetadata("design:type", target, key); //  property Object.defineProperty(target, key, { configurable: true, enumerable: true, get() { //  Injector       const instance = getInstance(getInjector(this), type); Object.defineProperty(this, key, { enumerable: true, writable: true, value: instance }); return instance; }, // settet     Dependency Injection set(instance) { Object.defineProperty(this, key, { enumerable: true, writable: true, value: instance }); } }); } 

الآن يمكننا تحديد التبعيات بين الطبقات التعسفية:


 import { inject } from "react-ioc"; class FooService {} class BarService { @inject foo: FooService; } class MyComponent extends React.Component { @inject foo: FooService; @inject bar: BarService; } 

بالنسبة لأولئك الذين لا يقبلون الديكور ، فإننا نحدد وظيفة inject() بهذا التوقيع:


 type Constructor<T> = new (...args: any[]) => T; function inject<T>(target: Object, type: Constructor<T> | Function): T; 

inject.js
 export function inject(target, keyOrType) { if (isFunction(keyOrType)) { return getInstance(getInjector(target), keyOrType); } // ... } 

سيتيح لك ذلك تحديد التبعيات بشكل صريح:


 class FooService {} class BarService { foo = inject(this, FooService); } class MyComponent extends React.Component { foo = inject(this, FooService); bar = inject(this, BarService); //   static contextType = InjectorContext; } 

ماذا عن المكونات الوظيفية؟ بالنسبة لهم يمكننا تنفيذ Hook useInstance()


hooks.js
 import { useRef, useContext } from "react"; export function useInstance(type) { const ref = useRef(null); const injector = useContext(InjectorContext); return ref.current || (ref.current = getInstance(injector, type)); } 

 import { useInstance } from "react-ioc"; const MyComponent = props => { const foo = useInstance(FooService); const bar = useInstance(BarService); return <div />; } 

الآن Injector ، وكيفية العثور عليه ، وكيفية حل التبعيات. يجب أن يحتوي الحاقن على مرجع إلى الأصل ، وذاكرة تخزين مؤقت للكائنات للتبعيات التي تم حلها بالفعل ، وقاموس لقواعد لم يتم حلها.


injector.js
 type Binding = (injector: Injector) => Object; export abstract class Injector extends React.Component { //    Injector _parent?: Injector; //    _bindingMap: Map<Function, Binding>; //      _instanceMap: Map<Function, Object>; } 

بالنسبة لمكونات this.context ، يتوفر Injector خلال حقل this.context ، وبالنسبة لفئات التبعية ، يمكننا وضع Injector مؤقتًا في متغير this.context . لتسريع عملية البحث عن محقن لكل فئة ، سنقوم بتخزين رابط Injector مؤقتًا في حقل مخفي.


injector.js
 export const INJECTOR = typeof Symbol === "function" ? Symbol() : "__injector__"; let currentInjector = null; export function getInjector(target) { let injector = target[INJECTOR]; if (injector) { return injector; } injector = currentInjector || target.context; if (injector instanceof Injector) { target[INJECTOR] = injector; return injector; } return null; } 

للعثور على قاعدة ربط محددة ، نحتاج إلى صعود شجرة الحاقن باستخدام getInstance()


injector.js
 export function getInstance(injector, type) { while (injector) { let instance = injector._instanceMap.get(type); if (instance !== undefined) { return instance; } const binding = injector._bindingMap.get(type); if (binding) { const prevInjector = currentInjector; currentInjector = injector; try { instance = binding(injector); } finally { currentInjector = prevInjector; } injector._instanceMap.set(type, instance); return instance; } injector = injector._parent; } return undefined; } 

أخيرًا ، دعنا ننتقل إلى تسجيل التبعيات. للقيام بذلك ، نحتاج إلى HOC provider() ، والذي يأخذ مجموعة من روابط التبعية في تطبيقاتهم ، ويقوم بتسجيل Injector جديد من خلال InjectorContext.Provider


provider.js
 export const provider = (...definitions) => Wrapped => { const bindingMap = new Map(); addBindings(bindingMap, definitions); return class Provider extends Injector { _parent = this.context; _bindingMap = bindingMap; _instanceMap = new Map(); render() { return ( <InjectorContext.Provider value={this}> <Wrapped {...this.props} /> </InjectorContext.Provider> ); } static contextType = InjectorContext; static register(...definitions) { addBindings(bindingMap, definitions); } }; }; 

وأيضًا ، مجموعة من الوظائف الملزمة التي تنفذ استراتيجيات متنوعة لإنشاء مثيلات التبعية.


bindings.js
 export const toClass = constructor => asBinding(injector => { const instance = new constructor(); if (!instance[INJECTOR]) { instance[INJECTOR] = injector; } return instance; }); export const toFactory = (depsOrFactory, factory) => asBinding( factory ? injector => factory(...depsOrFactory.map(type => getInstance(injector, type))) : depsOrFactory ); export const toExisting = type => asBinding(injector => getInstance(injector, type)); export const toValue = value => asBinding(() => value); const IS_BINDING = typeof Symbol === "function" ? Symbol() : "__binding__"; function asBinding(binding) { binding[IS_BINDING] = true; return binding; } export function addBindings(bindingMap, definitions) { definitions.forEach(definition => { let token, binding; if (Array.isArray(definition)) { [token, binding = token] = definition; } else { token = binding = definition; } bindingMap.set(token, binding[IS_BINDING] ? binding : toClass(binding)); }); } 

الآن يمكننا تسجيل روابط التبعية على مستوى مكون تعسفي في شكل مجموعة من الأزواج [<>, <>] .


 import { provider, toClass, toValue, toFactory, toExisting } from "react-ioc"; @provider( //    [FirstService, toClass(FirstServiceImpl)], //     [SecondService, toValue(new SecondServiceImpl())], //    [ThirdService, toFactory( [FirstService, SecondService], (first, second) => ThirdServiceFactory.create(first, second) )], //      [FourthService, toExisting(FirstService)] ) class MyComponent extends React.Component { // ... } 

أو في شكل مختصر للفصول الدراسية:


 @provider( // [FirstService, toClass(FirstService)] FirstService, // [SecondService, toClass(SecondServiceImpl)] [SecondService, SecondServiceImpl] ) class MyComponent extends React.Component { // ... } 

نظرًا لأن عمر الخدمة يتم تحديده بواسطة مكون المزود الذي تم تسجيله فيه ، لكل خدمة يمكننا تحديد طريقة تنظيف .dispose() . في ذلك ، يمكننا إلغاء الاشتراك من بعض الأحداث ، وإغلاق المقابس ، إلخ. عندما تقوم بإزالة الموفر من DOM ، فسوف يتصل .dispose() على جميع الخدمات التي ينشئها.


provider.js
 export const provider = (...definitions) => Wrapped => { // ... return class Provider extends Injector { // ... componentWillUnmount() { this._instanceMap.forEach(instance => { if (isObject(instance) && isFunction(instance.dispose)) { instance.dispose(); } }); } // ... }; }; 

لفصل الشفرة والتحميل البطيء ، قد نحتاج إلى عكس طريقة تسجيل الخدمات مع مقدمي الخدمات. سوف @registerIn() decoratorregisterIn @registerIn() هذا.


provider.js
 export const registrationQueue = []; export const registerIn = (getProvider, binding) => constructor => { registrationQueue.push(() => { getProvider().register(binding ? [constructor, binding] : constructor); }); return constructor; }; 

injector.js
 export function getInstance(injector, type) { if (registrationQueue.length > 0) { registrationQueue.forEach(registration => { registration(); }); registrationQueue.length = 0; } while (injector) { // ... } 

 import { registerIn } from "react-ioc"; import { HomePage } from "../components/HomePage"; @registerIn(() => HomePage) class MyLazyLoadedService {} 


لذلك ، بالنسبة إلى 150 سطرًا و 1 كيلوبايت من التعليمات البرمجية ، يمكنك تطبيق حاوية DI كاملة التسلسل الهرمي .


بنية التطبيق


أخيرًا ، دعنا ننتقل إلى الشيء الرئيسي - كيفية تنظيم بنية التطبيق. هناك ثلاثة خيارات ممكنة ، اعتمادًا على حجم التطبيق وتعقيد مجال الموضوع والكسل لدينا.


1. القبيح


لدينا DOM الظاهري ، مما يعني أنه ينبغي أن يكون سريعًا. على الأقل مع هذه الصلصة ، تم تقديم React في فجر مهنة. لذلك ، فقط تذكر الرابط إلى المكون الجذر (على سبيل المثال ، باستخدام @observer decorator). وسوف ندعو .forceUpdate() عليه بعد كل إجراء يؤثر على الخدمات المشتركة (على سبيل المثال ، باستخدام @action decorator)


observer.js
 export function observer(Wrapped) { return class Observer extends React.Component { componentDidMount() { observerRef = this; } componentWillUnmount() { observerRef = null; } render() { return <Wrapped {...this.props} />; } } } let observerRef = null; 

action.js
 export function action(_target, _key, descriptor) { const method = descriptor.value; descriptor.value = function() { let result; runningCount++; try { result = method.apply(this, arguments); } finally { runningCount--; } if (runningCount === 0 && observerRef) { observerRef.forceUpdate(); } return result; }; } let runningCount = 0; 

 class UserService { @action doSomething() {} } class MyComponent extends React.Component { @inject userService: UserService; } @provider(UserService) @observer class App extends React.Component {} 

سوف تعمل حتى. لكن ... أنت نفسك تفهم :-)


2. السيئة


نحن غير راضين عن تقديم كل شيء لكل عطس. لكننا ما زلنا نريد استخدام تقريبا الأجسام والمصفوفات العادية لتخزين الحالة. لنأخذ MobX !


نبدأ في تخزين العديد من البيانات مع الإجراءات القياسية:


 import { observable, action } from "mobx"; export class UserStore { byId = observable.map<number, User>(); @action add(user: User) { this.byId.set(user.id, user); } // ... } export class PostStore { // ... } 

نخرج منطق الأعمال ، I / O ، إلخ إلى طبقة الخدمات:


 import { action } from "mobx"; import { inject } from "react-ioc"; export class AccountService { @inject userStore userStore; @action updateUserInfo(userInfo: Partial<User>) { const user = this.userStore.byId.get(userInfo.id); Object.assign(user, userInfo); } } 

ونقوم بتوزيعها في مكونات:


 import { observer } from "mobx-react"; import { provider, inject } from "react-ioc"; @provider(UserStore, PostStore) class App extends React.Component {} @provider(AccountService) @observer class AccountPage extends React.Component{} @observer class UserForm extends React.Component { @inject accountService: AccountService; } 

الشيء نفسه ينطبق على المكونات الوظيفية وبدون الديكور
 import { action } from "mobx"; import { inject } from "react-ioc"; export class AccountService { userStore = inject(this, UserStore); updateUserInfo = action((userInfo: Partial<User>) => { const user = this.userStore.byId.get(userInfo.id); Object.assign(user, userInfo); }); } 

 import { observer } from "mobx-react-lite"; import { provider, useInstance } from "react-ioc"; const App = provider(UserStore, PostStore)(props => { // ... }); const AccountPage = provider(AccountService)(observer(props => { // ... })); const UserFrom = observer(props => { const accountService = useInstance(AccountService); // ... }); 

والنتيجة هي بنية كلاسيكية ثلاثية المستويات.


3. الخير


في بعض الأحيان يصبح مجال الموضوع معقدًا لدرجة أنه من غير المريح بالفعل التعامل معه باستخدام كائنات بسيطة (أو نموذج فقر الدم من حيث DDD). يكون هذا ملحوظًا بشكل خاص عندما تحتوي البيانات على بنية علائقية بها العديد من العلاقات. في مثل هذه الحالات ، تنقذ مكتبة MobX State Tree ، مما يسمح لك بتطبيق مبادئ التصميم المدفوع بالمجال في بنية تطبيق الواجهة الأمامية.


يبدأ تصميم النموذج بوصف للأنواع:


 // models/Post.ts import { types as t, Instance } from "mobx-state-tree"; export const Post = t .model("Post", { id: t.identifier, title: t.string, body: t.string, date: t.Date, rating: t.number, author: t.reference(User), comments: t.array(t.reference(Comment)) }) .actions(self => ({ voteUp() { self.rating++; }, voteDown() { self.rating--; }, addComment(comment: Comment) { self.comments.push(comment); } })); export type Post = Instance<typeof Post>; 

نماذج / User.ts
 import { types as t, Instance } from "mobx-state-tree"; export const User = t.model("User", { id: t.identifier, name: t.string }); export type User = Instance<typeof User>; 

نماذج / تعليق
 import { types as t, Instance } from "mobx-state-tree"; import { User } from "./User"; export const Comment = t .model("Comment", { id: t.identifier, text: t.string, date: t.Date, rating: t.number, author: t.reference(User) }) .actions(self => ({ voteUp() { self.rating++; }, voteDown() { self.rating--; } })); export type Comment = Instance<typeof Comment>; 

ونوع مخزن البيانات:


 // models/index.ts import { types as t } from "mobx-state-tree"; export { User, Post, Comment }; export default t.model({ users: t.map(User), posts: t.map(Post), comments: t.map(Comment) }); 

تحتوي أنواع الكيانات على حالة نموذج المجال والعمليات الأساسية معه. يتم تنفيذ سيناريوهات أكثر تعقيدًا ، بما في ذلك I / O ، في طبقة الخدمات.


خدمات / DataContext.ts
 import { Instance, unprotect } from "mobx-state-tree"; import Models from "../models"; export class DataContext { static create() { const models = Models.create(); unprotect(models); return models; } } export interface DataContext extends Instance<typeof Models> {} 

خدمات / AuthService.ts
 import { observable } from "mobx"; import { User } from "../models"; export class AuthService { @observable currentUser: User; } 

الخدمات / PostService.ts
 import { inject } from "react-ioc"; import { action } from "mobx"; import { Post } from "../models"; export class PostService { @inject dataContext: DataContext; @inject authService: AuthService; async publishPost(postInfo: Partial<Post>) { const response = await fetch("/posts", { method: "POST", body: JSON.stringify(postInfo) }); const { id } = await response.json(); this.savePost(id, postInfo); } @action savePost(id: string, postInfo: Partial<Post>) { const post = Post.create({ id, rating: 0, date: new Date(), author: this.authService.currentUser.id, comments: [], ...postInfo }); this.dataContext.posts.put(post); } } 

الميزة الرئيسية في MobX State Tree هي العمل الفعال مع لقطات البيانات. في أي وقت ، يمكننا الحصول على الحالة التسلسلية لأي كيان أو مجموعة أو حتى حالة التطبيق بأكملها باستخدام وظيفة getSnapshot() . وبنفس الطريقة ، يمكننا تطبيق اللقطة على أي جزء من النموذج باستخدام applySnapshot() . يتيح لنا ذلك تهيئة الحالة من الخادم في عدة سطور من التعليمات البرمجية أو التحميل من LocalStorage أو حتى التفاعل معها من خلال Redux DevTools.


بما أننا نستخدم نموذجًا علائقيًا طبيعيًا ، فإننا نحتاج إلى مكتبة normalizr لتحميل البيانات. يسمح لك بترجمة شجرة JSON إلى جداول مسطحة للكائنات المجمعة حسب id ، وفقًا لنظام البيانات. فقط بالتنسيق الذي تحتاجه MobX State Tree كقطة.


للقيام بذلك ، حدد مخططات الكائنات التي تم تنزيلها من الخادم:


 import { schema } from "normalizr"; const UserSchema = new schema.Entity("users"); const CommentSchema = new schema.Entity("comments", { author: UserSchema }); const PostSchema = new schema.Entity("posts", { //   - //      author: UserSchema, comments: [CommentSchema] }); export { UserSchema, PostSchema, CommentSchema }; 

وتحميل البيانات في التخزين:


 import { inject } from "react-ioc"; import { normalize } from "normalizr"; import { applySnapshot } from "mobx-state-tree"; export class PostService { @inject dataContext: DataContext; // ... async loadPosts() { const response = await fetch("/posts.json"); const posts = await response.json(); const { entities } = normalize(posts, [PostSchema]); applySnapshot(this.dataContext, entities); } // ... } 

posts.json
 [ { "id": 123, "title": "    React", "body": "  -     React...", "date": "2018-12-10T18:18:58.512Z", "rating": 0, "author": { "id": 12, "name": "John Doe" }, "comments": [{ "id": 1234, "text": "Hmmm...", "date": "2018-12-10T18:18:58.512Z", "rating": 0, "author": { "id": 12, "name": "John Doe" } }] }, { "id": 234, "title": "Lorem ipsum", "body": "Lorem ipsum dolor sit amet...", "date": "2018-12-10T18:18:58.512Z", "rating": 0, "author": { "id": 23, "name": "Marcus Tullius Cicero" }, "comments": [] } ] 

أخيرًا ، سجل الخدمات في المكونات المناسبة:


 import { observer } from "mobx-react"; import { provider, inject } from "react-ioc"; @provider(AuthService, PostService, [ DataContext, toFactory(DataContext.create) ]) class App extends React.Component { @inject postService: PostService; componentDidMount() { this.postService.loadPosts(); } } 

لقد ظهر كل نفس بنية الطبقات الثلاثة ، ولكن مع القدرة على الحفاظ على التحقق من الحالة والوقت لأنواع البيانات (في وضع DEV). يتيح لك هذا الأخير التأكد من أنه في حالة عدم وجود استثناء ، فإن حالة مستودع البيانات تتوافق مع المواصفات.







بالنسبة لأولئك الذين كانوا مهتمين ، وصلة لجيثب والعرض التجريبي .

Source: https://habr.com/ru/post/ar431942/


All Articles