Skip to content

前端框架

前端示例在页面、路由、屏幕或功能边界处创建作用域。它们各自保留小型的构建器,而不是导入服务端的共享模块。

请对比每个框架在何处创建作用域、如何将作用域提供给子组件,以及在卸载时如何执行清理。

示例展示内容
react.tsxReact 功能作用域,配合惰性 useState 与清理
react-native.tsxReact Native 屏幕作用域
vue.tsVue 3 provide/inject 作用域边界
svelte.tsSvelte context 作用域边界

React

tsx
import { Container } from '@inferdi/inferdi'
import {
  createContext,
  type PropsWithChildren,
  useContext,
  useEffect,
  useState,
} from 'react'

// Frontend examples keep their own minimal builder because the browser does
// not have the `Database` / `process.env` shape that `_shared/container.ts`
// uses on the server. The patterns are the same: scoped `FeatureContext`,
// singleton services on the root, page-level scope on mount.

class FeatureContext {
  featureName = ''
}

class ApiClient {
  async listProjects() {
    return [{ id: 'project_1', name: 'Docs' }]
  }
}

class ProjectsViewModel {
  constructor(
    private readonly feature: FeatureContext,
    private readonly api: ApiClient,
  ) {}

  async load() {
    return {
      feature: this.feature.featureName,
      projects: await this.api.listProjects(),
    }
  }
}

const root = new Container()
  .registerClass('feature', FeatureContext, [], 'scoped')
  .registerClass('api', ApiClient, [])
  .registerClass('projectsVm', ProjectsViewModel, ['feature', 'api'], 'scoped')

type AppContainer = typeof root
type PageContainer = ReturnType<typeof createProjectsPageScope>

function createProjectsPageScope(parent: AppContainer) {
  const scope = parent.createScope()
  try {
    scope.get('feature').featureName = 'projects'
    return scope
  } catch (error) {
    scope.dispose().catch(console.error)
    throw error
  }
}

const RootDIContext = createContext<AppContainer | null>(null)
const PageDIContext = createContext<PageContainer | null>(null)

export function AppDIProvider({ children }: PropsWithChildren) {
  return <RootDIContext.Provider value={root}>{children}</RootDIContext.Provider>
}

export function ProjectsPage({ children }: PropsWithChildren) {
  const parent = useContext(RootDIContext)
  if (parent === null) throw new Error('DI provider is missing')

  // useState with a lazy initializer is the correct primitive for "create
  // exactly once per mounted instance". `useMemo` is documented as an
  // optimization, not a guarantee — under concurrent rendering React may
  // re-run the factory at any time and the discarded scope would leak
  // because `useEffect` cleanup only runs on the kept render.
  const [scope] = useState(() => createProjectsPageScope(parent))

  useEffect(() => {
    // React cleanup is synchronous. Use async dispose explicitly so scopes
    // with async factories/disposers do not go through sync [Symbol.dispose]().
    return () => {
      scope.dispose().catch(console.error)
    }
  }, [scope])

  return <PageDIContext.Provider value={scope}>{children}</PageDIContext.Provider>
}

export function useProjectsViewModel() {
  const container = useContext(PageDIContext)
  if (container === null) throw new Error('Projects page scope is missing')
  return container.get('projectsVm')
}

仓库文件:examples/frontend/react.tsx

React Native

tsx
import { Container } from '@inferdi/inferdi'
import {
  createContext,
  type PropsWithChildren,
  useContext,
  useEffect,
  useState,
} from 'react'

class ScreenContext {
  screenName = ''
}

class DeviceStorage {
  async getItem(key: string) {
    return `value:${key}`
  }
}

class SettingsViewModel {
  constructor(
    private readonly screen: ScreenContext,
    private readonly storage: DeviceStorage,
  ) {}

  async loadTheme() {
    return {
      screen: this.screen.screenName,
      theme: await this.storage.getItem('theme'),
    }
  }
}

const root = new Container()
  .registerClass('screen', ScreenContext, [], 'scoped')
  .registerClass('storage', DeviceStorage, [])
  .registerClass('settingsVm', SettingsViewModel, ['screen', 'storage'], 'scoped')

type AppContainer = typeof root
type ScreenContainer = ReturnType<typeof createSettingsScreenScope>

function createSettingsScreenScope(parent: AppContainer) {
  const scope = parent.createScope()
  try {
    scope.get('screen').screenName = 'settings'
    return scope
  } catch (error) {
    scope.dispose().catch(console.error)
    throw error
  }
}

const RootDIContext = createContext<AppContainer | null>(null)
const ScreenDIContext = createContext<ScreenContainer | null>(null)

export function AppDIProvider({ children }: PropsWithChildren) {
  return <RootDIContext.Provider value={root}>{children}</RootDIContext.Provider>
}

export function SettingsScreenScope({ children }: PropsWithChildren) {
  const parent = useContext(RootDIContext)
  if (parent === null) throw new Error('DI provider is missing')

  // useState with a lazy initializer (not useMemo) — see the React example
  // for the rationale: useMemo is not a guarantee, lazy useState is.
  const [scope] = useState(() => createSettingsScreenScope(parent))

  useEffect(() => {
    return () => {
      scope.dispose().catch(console.error)
    }
  }, [scope])

  return <ScreenDIContext.Provider value={scope}>{children}</ScreenDIContext.Provider>
}

export function useSettingsViewModel() {
  const container = useContext(ScreenDIContext)
  if (container === null) throw new Error('Settings screen scope is missing')
  return container.get('settingsVm')
}

仓库文件:examples/frontend/react-native.tsx

Vue

ts
import { Container } from '@inferdi/inferdi'
import {
  defineComponent,
  inject,
  onUnmounted,
  provide,
  type InjectionKey,
} from 'vue'

class RouteContext {
  routeName = ''
}

class ApiClient {
  async dashboard() {
    return { total: 42 }
  }
}

class DashboardViewModel {
  constructor(
    private readonly route: RouteContext,
    private readonly api: ApiClient,
  ) {}

  async load() {
    return {
      route: this.route.routeName,
      dashboard: await this.api.dashboard(),
    }
  }
}

const root = new Container()
  .registerClass('route', RouteContext, [], 'scoped')
  .registerClass('api', ApiClient, [])
  .registerClass('dashboardVm', DashboardViewModel, ['route', 'api'], 'scoped')

type AppContainer = typeof root
type RouteContainer = ReturnType<typeof createDashboardRouteScope>

function createDashboardRouteScope(parent: AppContainer) {
  const scope = parent.createScope()
  try {
    scope.get('route').routeName = 'dashboard'
    return scope
  } catch (error) {
    scope.dispose().catch(console.error)
    throw error
  }
}

const RootDIKey: InjectionKey<AppContainer> = Symbol('InferDI.root')
const RouteDIKey: InjectionKey<RouteContainer> = Symbol('InferDI.route')

export const AppDIProvider = defineComponent({
  setup(_props, { slots }) {
    provide(RootDIKey, root)
    return () => slots.default?.()
  },
})

export const DashboardRouteProvider = defineComponent({
  setup(_props, { slots }) {
    const parent = inject(RootDIKey)
    if (parent === undefined) throw new Error('DI provider is missing')

    const scope = createDashboardRouteScope(parent)
    provide(RouteDIKey, scope)

    // Vue unmount hooks are synchronous; call async dispose explicitly.
    onUnmounted(() => {
      scope.dispose().catch(console.error)
    })

    return () => slots.default?.()
  },
})

export function useDashboardViewModel() {
  const container = inject(RouteDIKey)
  if (container === undefined) throw new Error('Dashboard route scope is missing')
  return container.get('dashboardVm')
}

仓库文件:examples/frontend/vue.ts

Svelte

ts
import { Container } from '@inferdi/inferdi'
import { getContext, onDestroy, setContext } from 'svelte'

class RouteContext {
  routeName = ''
}

class ApiClient {
  async loadInbox() {
    return [{ id: 'msg_1', subject: 'Hello' }]
  }
}

class InboxViewModel {
  constructor(
    private readonly route: RouteContext,
    private readonly api: ApiClient,
  ) {}

  async load() {
    return {
      route: this.route.routeName,
      messages: await this.api.loadInbox(),
    }
  }
}

const root = new Container()
  .registerClass('route', RouteContext, [], 'scoped')
  .registerClass('api', ApiClient, [])
  .registerClass('inboxVm', InboxViewModel, ['route', 'api'], 'scoped')

type AppContainer = typeof root
type RouteContainer = ReturnType<typeof createInboxRouteScope>

const RootDIKey = Symbol('InferDI.root')
const RouteDIKey = Symbol('InferDI.route')

function createInboxRouteScope(parent: AppContainer) {
  const scope = parent.createScope()
  try {
    scope.get('route').routeName = 'inbox'
    return scope
  } catch (error) {
    scope.dispose().catch(console.error)
    throw error
  }
}

export function provideRootContainer() {
  setContext(RootDIKey, root)
}

export function provideInboxRouteScope() {
  const parent = getContext<AppContainer | undefined>(RootDIKey)
  if (parent === undefined) {
    throw new Error('InferDI root context is missing — call provideRootContainer() in a parent layout')
  }
  const scope = createInboxRouteScope(parent)

  setContext(RouteDIKey, scope)

  // Svelte onDestroy is synchronous; start async cleanup and report failures.
  onDestroy(() => {
    scope.dispose().catch(console.error)
  })
}

export function getInboxViewModel() {
  const scope = getContext<RouteContainer | undefined>(RouteDIKey)
  if (scope === undefined) {
    throw new Error('Inbox route scope is missing — call provideInboxRouteScope() in this route')
  }
  return scope.get('inboxVm')
}

仓库文件:examples/frontend/svelte.ts