PAYATHON 2026

ember.js 从 v4.12 升级到 v5 时如何迁移动态 Model 属性?

技术老齐

结论

可以继续使用现有的 Models,无需为了消除 early static deprecation 就把整个项目改成自定义 SchemaDefinitionService。

这类动态扩展逻辑建议先调整初始化顺序:读取一个固定的基础 Model,合并远程 schema,然后生成并注册最终 Model。Store 开始使用最终类型后,就不要再替换它。

如果需要将 schema 与 Model 类分开维护,可以注册自定义 schema 服务,把普通类型交给默认服务处理。自定义 schema 服务只提供字段定义,不会自动为 @ember-data/model 实例生成对应的属性访问器。

这些都是 Ember Data 的 API。迁移时应核对 @ember-data/store、@ember-data/model 的实际版本,不能只看 ember-source 的版本。

弃用警告与缓存问题需要分别处理

early static deprecation 针对的是直接导入 Model 类,未经 Store 查找就读取其静态元数据的做法,例如:

import Product from 'my-app/models/product';

const relationships = Product.relationshipsByName;

官方给出的过渡写法是:

const Product = store.modelFor('product');
const relationships = Product.relationshipsByName;

更适合后续迁移的查询方式是:

const schema = store.getSchemaDefinitionService();

const attributes = schema.attributesDefinitionFor({
  type: 'product',
});

const relationships = schema.relationshipsDefinitionFor({
  type: 'product',
});

官方弃用说明给出了这两种写法。静态元数据仍然可以读取,但要通过相应的查找入口。

更换查询入口无法解决 Model 重新注册的问题。Ember 的容器注册与 Ember Data 内部的类型、schema 缓存是两套机制。unregister() 后再 register() 并不是刷新 Store schema 的公开接口,Store 内部还会缓存 Model factory。参见 Store 实现。

改动较小的方案:基础类型与最终类型分开

如果远程 schema 在初始化时确定,之后保持不变,可以继续根据已有 Model 生成新 Model,只调整类型划分和执行时机:

  1. 保留一个基础类型,例如 base-product,读取后不再修改。
  2. 请求远程 schema,合并基础属性和关系。
  3. 创建最终类型 product,在 Store 第一次查询或实例化它之前完成注册。
  4. 后续请求都使用这个已经确定的 product。

由于基础类型不会被替换,读取 base-product 时产生的缓存可以保留。

以下初始化函数使用 Ember Data 4.12 时期的 Model 和 schema API,假设远程定义已转换为 attributesDefinitionFor()、relationshipsDefinitionFor() 使用的字典结构:

import { getOwner } from '@ember/application';
import { attr, belongsTo, hasMany } from '@ember-data/model';

export function registerProduct(store, remoteSchema) {
  const BaseProduct = store.modelFor('base-product');
  const schema = store.getSchemaDefinitionService();
  const identifier = { type: 'base-product' };

  const attributes = {
    ...schema.attributesDefinitionFor(identifier),
    ...remoteSchema.attributes,
  };

  const relationships = {
    ...schema.relationshipsDefinitionFor(identifier),
    ...remoteSchema.relationships,
  };

  const properties = {};

  for (const [name, definition] of Object.entries(attributes)) {
    properties[name] = attr(
      definition.type,
      definition.options ?? {}
    );
  }

  for (const [name, definition] of Object.entries(relationships)) {
    const relationship =
      definition.kind === 'belongsTo' ? belongsTo : hasMany;

    properties[name] = relationship(
      definition.type,
      definition.options
    );
  }

  const Product = BaseProduct.extend(properties);

  getOwner(store).register('model:product', Product);
}

使用这个示例时,需要满足以下条件:

  • product 尚未被 Store 使用,也没有需要替换的已有注册。
  • 远程 schema 已经过校验,尤其是关系的 kind、type 和 options。
  • 合并按字段覆盖,同名字段会替换整个定义。如果要保留原来的 options,需要在合并函数中显式处理。
  • 示例支持新增和覆盖字段,不处理继承字段的删除,也不处理属性与关系之间的相互转换。

可以在 beforeModel() 中等待远程请求和注册完成,再进入 model()。但这只能保证当前路由的执行顺序。如果父路由、服务或其他初始化代码已经访问过 product,此时注册仍然太晚。建议通过一个共享 Promise 管理初始化,避免重复进入路由时再次注册。

如果最终类型与后端资源类型不同,还需要检查 Adapter、Serializer 的类型映射,以及关系另一端的 inverse。

装饰器中的转换逻辑怎么迁移

类装饰器在模块求值期间执行,不适合通过 Store 读取同一个 Model 的 schema,再修改这个类。

可以让装饰器只保存转换规则,等初始化阶段拿到 Store 和远程 schema 后再应用。也可以将基础字段定义提取成独立配置,供 Model 生成逻辑和 schema 合并逻辑共同使用。

转换函数由此可以直接接收普通字段定义,无需为了读取原有字段而提前触发最终 Model 的查找和缓存。

自定义 SchemaDefinitionService 如何与现有 Models 共存

自定义服务可以处理指定类型,其他类型则委托给默认的 Model schema 服务。Store schema 注册文档提供了这种组合方式,无需一次迁移所有 Models。

以下示例只处理 schema 层,definitions 保存动态类型合并后的完整定义:

// app/utils/hybrid-schema.js
export default class HybridSchema {
  constructor(fallback) {
    this.fallback = fallback;
    this.definitions = new Map();
  }

  define(type, definition) {
    this.definitions.set(type, definition);
  }

  doesTypeExist(type) {
    return (
      this.definitions.has(type) ||
      this.fallback.doesTypeExist(type)
    );
  }

  attributesDefinitionFor(identifier) {
    const definition = this.definitions.get(identifier.type);

    return definition
      ? definition.attributes
      : this.fallback.attributesDefinitionFor(identifier);
  }

  relationshipsDefinitionFor(identifier) {
    const definition = this.definitions.get(identifier.type);

    return definition
      ? definition.relationships
      : this.fallback.relationshipsDefinitionFor(identifier);
  }
}

在 Store 初始化时保存默认服务,再注册包装后的服务:

// app/services/store.js
import Store from '@ember-data/store';
import HybridSchema from '../utils/hybrid-schema';

export default class ApplicationStore extends Store {
  constructor(...args) {
    super(...args);

    const fallback = this.getSchemaDefinitionService();

    this.dynamicSchema = new HybridSchema(fallback);
    this.registerSchema(this.dynamicSchema);
  }
}

define() 是示例自行实现的方法,不是 Ember Data API。registerSchema() 在 Ember Data 4.12.3 中已经存在,旧的 registerSchemaDefinitionService() 则在该版本源码中被标注为弃用。迁移时应以目标版本的 API 为准。参见 4.12.3 Store 源码。

合并动态定义时,可以通过保存的 fallback 查询基础类型,再将合并结果存入 definitions。不要在包装服务的方法中重新向 Store 获取当前 schema 服务,否则可能递归调用自身。

自定义 schema 还不能代替完整的 Model 实现

Schema 服务描述字段名称、属性类型和关系信息。应用可以自行提供这些定义,包括提前请求 schema,或按需推导 schema。参见 SchemaService 文档。

record 的读写行为仍需另外处理:

  • 继续使用 @ember-data/model 时,新增字段还需要对应的 attr()、belongsTo() 或 hasMany() 定义,并与 schema 保持一致。
  • 使用自定义 record 类时,需要实现与 Cache 的交互,以及 Store 的 instantiateRecord()、teardownRecord() 等生命周期接口。参见 Store 生命周期接口。
  • 修改自定义服务中的定义后,已有实例、关系图和其他消费者不会因此自动重新解释旧数据。

如果只需要在启动时根据远程配置确定字段,优先采用一次生成、一次注册的方案。如果同一个类型需要在已有记录存活期间改变属性类型或关系结构,就需要同时设计数据迁移和实例生命周期,单独替换 schema 字典无法完成这些工作。

迁移后,应重点验证新增字段的读写与序列化、关系的 inverse,以及重复进入路由时是否仍只初始化一次。

备注:内容仅供参考。