
教程文档【免费下载链接】30-seconds-of-codeCoding articles to level up your development skills项目地址https://gitcode.com/gh_mirrors/30/30-seconds-of-code点击查看免费下载本文是 30-seconds-of-code 仓库内《Modeling complex JavaScript objects》系列文章的第二篇对应文档 complex-object-attributes-relationships.md继续深入探讨如何在 JavaScript 中以 ActiveRecord 风格建模复杂对象。上一篇 complex-object-collections-in-memory.md 讲解了Model与RecordSet的内存集合基础本篇则聚焦模型属性attributes与模型间关系relationships并引入Map索引来优化id查询性能。读完本文你将掌握如何用 getter 函数实现数据属性与计算属性、如何用单条/多条关系串联模型实例、如何基于关系与计算属性做高级查询以及如何用索引存储提升查找效率。目录结构在上一篇实现的Model内存存储与RecordSet查询集合基础之上本篇新增了一个Author模型并与既有Post模型建立关系。新增后的项目结构如下src/ ├── core/ │ ├── model.js │ └── recordSet.js └── models/ ├── author.js └── post.js其中core/目录是基础设施层models/目录则存放具体的业务模型。若对Model/RecordSet的原始实现有遗忘可回看上一篇文档附带的代码摘要部分。模型属性Model attributes在 ActiveRecord 模式中模型的属性attributes相当于数据库中的列columns。模型还可以定义一些与属性行为类似的方法用于返回对模型数据做复杂运算后的结果。JavaScript 中实现这一点的关键是常常被低估的getter 函数——它可以让我们以访问普通属性的方式无缝地读取对象属性与方法计算的结果这一决策在后续关系建模中也会非常受用。数据属性Data attributes与其改造Post不如新建一个Author模型用来存放作者的信息包括姓名name、姓氏surname、邮箱email并同样带上id上一篇中Post模型已具备。Author模型定义如下import Model from #src/core/model.js; export default class Author extends Model { static { // Prepare storage for the Author model super.prepare(this); } constructor(data) { super(data); this.id data.id; this.name data.name; this.surname data.surname; this.email data.email; } }这个简单的模型定义即为我们提供了id、name、surname、email四个属性。我们称它们为数据属性data attributes以区别于接下来要定义的计算属性calculated attributes。[!NOTE] 这里的数据属性/计算属性之分更多是语义层面的约定数据属性直接存储在对象自身而计算属性由对象的数据派生而来。这一区分只是为了帮助读者理解两类属性的本质差异。计算属性Calculated attributes现在给Author模型添加一个计算属性fullName用于拼接name与surname。实现方式只需一个 getter 函数import Model from #src/core/model.js; export default class Author extends Model { // ... get fullName() { return this.surname ? ${this.name} ${this.surname} : this.name; } }这里特意处理了surname为空的情况——我们视它为可选字段因此fullName在缺省姓氏时只返回名字。这个例子展示了计算属性的价值把逻辑隐藏在看似简单的属性背后调用方无需关心拼接细节。时间敏感属性Temporal attributes有些计算属性需要与外部数据或当前时间比较。以Post模型为例我们定义一个依赖当前日期的计算属性isPublished。先让构造函数接收publishedAt属性import Model from #src/core/model.js; export default class Post extends Model { // ... constructor(data) { super(data); this.id data.id; this.title data.title; this.content data.content; this.publishedAt data.publishedAt; } }随后定义isPublished计算属性将publishedAt与当前时间比较并返回布尔值。import Model from #src/core/model.js; export default class Post extends Model { // ... get isPublished() { return this.publishedAt new Date(); } }注意publishedAt的取值单位如时间戳 vsDate实例会直接影响比较结果实际项目中应保持数据写入与比较口径一致。模型关系Model relationshipsActiveRecord 模式中模型之间可以存在一对一one-to-one、一对多one-to-many和多对多many-to-many关系。一对一与一对多相对容易实现多对多则更棘手。本文刻意简化约定将关系分为两类单条关系single relationship模型实例引用另一个模型的单个实例多条关系multiple relationship模型实例引用另一个模型的多个实例。单条关系Single relationships单条关系实现起来很直接。我们为Post模型建立到Author的单条关系——每篇文章恰好有一个作者。为此要利用之前偷偷塞进各模型的id属性并给Model类补充一个static的find方法按id检索并返回模型实例export default class Model { // ... static find(id) { return this.all.find(model model.id id); } }[!TIP] 方法中的this指向调用它的类Author.find(id)返回Author实例Post.find(id)返回Post实例。接着更新Post模型加入authorId属性import Model from #src/core/model.js; export default class Post extends Model { // ... constructor(data) { super(data); this.id data.id; this.title data.title; this.content data.content; this.publishedAt data.publishedAt; this.authorId data.authorId; } }关联记录检索Related record retrieval现在Post有了authorId但如何通过它拿到真正的Author实例答案还是在Post模型中定义一个 getter借助find方法import Model from #src/core/model.js; import Author from #src/models/author.js; export default class Post extends Model { // ... get author() { return Author.find(this.authorId); } }严格来说author也是一个计算属性但它很特殊——它返回的是另一个模型实例这正是单条关系的本质。const author new Author({ id: 1, name: John, surname: Doe, email: j.doeauthornet.io }); const post new Post({ id: 1, title: Hello, World!, content: This is my first post., publishedAt: new Date(2024-12-08), authorId: author.id }); post.author; // Author { id: 1, name: John, surname: Doe, email: j.doeauthornet.io } post.author.fullName; // John Doe可以看到post.author直接返回完整的Author实例且能继续访问其计算属性fullName关系链自然贯通。多条关系Multiple relationships上一节刻意略过了关系的另一侧——目前没有任何Author实例能方便地取回该作者的所有文章。这正是多条关系要解决的场景一个模型实例引用另一个模型的多个实例。Author实例中并没有任何指向Post的id如何求解思路与之前如出一辙posts关系就是一个计算属性任务是取回某作者的全部文章。既然我们知道作者的id又拥有RecordSet的where方法可按任意条件匹配记录于是import Model from #src/core/model.js; import Post from #src/models/post.js; export default class Author extends Model { // ... get posts() { return Post.where({ authorId: this.id }); } }寥寥几行代码就完成了从Author到Post的关系。更复杂的关系链也可以用类似方式构建——把多个计算属性串联起来即可。const author new Author({ id: 1, name: John, surname: Doe, email: j.doeauthornet.io }); const post1 new Post({ id: 1, title: Hello, World!, content: This is my first post., publishedAt: new Date(2024-12-08), authorId: author.id }); const post2 new Post({ id: 2, title: Goodbye, World!, content: This is my last post., publishedAt: new Date(2024-12-12), authorId: author.id }); author.posts; // [Post { id: 1}, Post { id: 2 }] // 假设当前日期为 2024-12-10 author.posts.where(post post.isPublished); // [Post { id: 1 }]注意author.posts返回的是RecordSet因此可以继续链式调用where而where又支持传入函数作为条件isPublished这类计算属性自然也能参与过滤。[!NOTE] 本文刻意回避了多对多关系主要为了保持简洁。如需建模多对多可在模型实例中存储一个id数组再用where查询取回相关实例关系的另一侧与Author.posts类似只是需要把where查询与pluck结合来取得相关id集合。高级查询Advanced querying这个实现的一个涌现特性emergent behavior是可以基于模型的所有属性进行查询而不只限于数据属性。这为复杂查询与关系操作打开了大量可能性。查询计算属性Querying calculated attributes计算属性以 getter 实现因此where方法可以直接以任何方式查询它们pluck、select以及Array.prototype.map()等内置方法同理。// 沿用上一示例的 posts以及相同的当前日期 const posts Post.all; posts.where(post post.isPublished); // [Post { id: 1 }] posts.pluck(title); // [Hello, World!, Goodbye, World!] posts.map(post post.isPublished); // [true, false]查询关系Querying relationships关系本质上只是被赋予了特殊含义的计算属性因此也能像其他属性一样参与查询。const authors Author.all; authors.where(author author.posts.length 1); // Author { id: 1 } authors.pluck(posts); // [[Post { id: 1 }, Post { id: 2 }] authors.map(author author.posts.length); // [2][!TIP] 细心的读者会发现几乎所有属性在查询层面都表现出一致的特质。这为通过中间模型建立关系铺平了道路——例如「属于某作者某篇文章的评论」可以经由Author实例本身一路查询下去。这难免让人联想到 ActiveRecord 的:through关联。优化id查询Optimizingidqueries回顾一下按id查询记录是最频繁的操作之一。此前记录的检索已集中到Model类上的中央存储instances但这还不够。进一步的优化是增加一份带索引的存储indexed storage基于Map实现除instances外再维护indexedInstances让每个模型拥有自己的Map从而按id快速取回记录。为此需要同步更新prepare方法与Model的构造函数自然也不能忘了find——通过改变其底层实现就相当于优化了所有依赖id属性的查询export default class Model { static instances {}; static indexedInstances {}; static prepare(model) { const name model.name; // Create an array for each model to store instances if (!Model.instances[name]) Model.instances[name] []; // Create a map to speed up queries if (!Model.indexedInstances[name]) { Model.indexedInstances[name] new Map(); } } constructor(data) { const modelName this.constructor.name; Model.instances[modelName].push(this); Model.indexedInstances[modelName].set(data.id, this); } static find(id) { return Model.indexedInstances[this.name].get(id); } }这是一次简单但收益明显的优化find从遍历数组改为Map.get()复杂度由 O(n) 降为 O(1)在记录数量较大时对应用性能提升显著。[!NOTE] 在实际项目中作者并未完全照此实现主要区别在于把id做成每个模型可自行配置的一组索引属性。该做法会让代码稍复杂当前阶段收益不大但读者完全可以自行推导实现。事实上本仓库 src/core/model.js 中的prepare(model, indexes)正是这种可配置索引思想的体现——它支持为每个模型声明一个或多个索引字段并为每个索引维护独立的Map同时仓库实现还通过WeakMap对 getter 计算结果做了缓存src/core/model.js进一步避免重复计算。完整实现代码摘要Code summary以下是本系列到目前为止的完整实现可作为参考或直接运行测试。仓库对应的模型与核心代码见 src/core/model.js、src/core/recordSet.js 及 src/models/ 目录。import RecordSet from #src/core/recordSet.js; export default class Model { static instances {}; static indexedInstances {}; static prepare(model) { const name model.name; // Create an array for each model to store instances if (!Model.instances[name]) Model.instances[name] []; // Create a map to speed up queries if (!Model.indexedInstances[name]) { Model.indexedInstances[name] new Map(); } } constructor(data) { const modelName this.constructor.name; // Store the instance in the instances and indexedInstances Model.instances[modelName].push(this); Model.indexedInstances[modelName].set(data.id, this); } static get all() { return RecordSet.from(Model.instances[this.name] || []); } static where(query) { return this.all.where(query); } static find(id) { return Model.indexedInstances[this.name].get(id); } }export default class RecordSet extends Array { where(query) { return RecordSet.from( this.filter(record { return Object.keys(query).every(key { // If function use it to determine matches if (typeof query[key] function) return querykey; // If array, use it to determine matches if (Array.isArray(query[key])) return query[key].includes(record[key]); // If single value, use strict equality return record[key] query[key]; }); }) ); } pluck(attribute) { return RecordSet.from(super.map(record record[attribute])) } select(...attributes) { return RecordSet.from(super.map(record attributes.reduce((acc, attribute) { acc[attribute] record[attribute]; return acc; }, {}) )); } get first() { return this[0]; } get last() { return this[this.length - 1]; } }import Model from #src/core/model.js; import Author from #src/models/author.js; export default class Post extends Model { static { // Prepare storage for the Post model super.prepare(this); } constructor(data) { super(data); this.id data.id; this.title data.title; this.content data.content; this.publishedAt data.publishedAt; this.authorId data.authorId; } get isPublished() { return this.publishedAt new Date(); } get author() { return Author.find(this.authorId); } }import Model from #src/core/model.js; import Post from #src/models/post.js; export default class Author extends Model { static { // Prepare storage for the Author model super.prepare(this); } constructor(data) { super(data); this.id data.id; this.name data.name; this.surname data.surname; this.email data.email; } get fullName() { return this.surname ? ${this.name} ${this.surname} : this.name; } get posts() { return Post.where({ authorId: this.id }); } }仓库中的实际应用印证本文的设计模式并非纸面示例它正是 30-seconds-of-code 自身内容模型的基础设施。看 src/models/contentModel.js 即可发现ContentModel继承自Model并在静态块中调用Model.prepare(this, [])src/models/contentModel.js——这是文档「静态块 super.prepare」约定的真实落地slug、url、isSnippet、coverUrl等大量成员都以getter 计算属性的形式定义src/models/contentModel.js与本文「用 getter 隐藏复杂性」的思路完全一致查询侧则通过where与order等方法完成例如listed、byRankingsrc/models/contentModel.js对应RecordSet的查询能力。对应的单元测试 spec/core/model.test.js 也验证了这套基础设施的行为all返回全部记录、where支持精确值/数组/函数三种匹配形式、find按id精确检索、order排序以及scope作用域组合spec/core/model.test.js。这些测试直接印证了文中各查询方法的语义与用法。结语在本系列的第二篇中我们聚焦于复杂对象属性与关系的建模。一开始听起来可能有些吓人但实际并不难——借助 JavaScript 面向对象的特性与内置方法可以相当轻松地抽象出复杂逻辑。getter 函数让我们得以统一数据属性与计算属性idfind支撑起单条关系where 计算属性支撑起多条关系而Map索引则让最频繁的id查询保持高效。这个项目还有更多值得探索的内容——敬请期待后续篇章对更高级行为、实现细节与优化的深入讲解。延伸阅读上一篇complex-object-collections-in-memory.mdModel与RecordSet的内存集合基础同系列其余章节见 complex-objects.yaml 中列出的snippetIds涵盖 scopes、serialization、factories、field validation、advanced relationships 等主题仓库核心实现src/core/model.js、src/core/recordSet.js单元测试spec/core/model.test.js赞分享教程文档【免费下载链接】30-seconds-of-codeCoding articles to level up your development skills项目地址https://gitcode.com/gh_mirrors/30/30-seconds-of-code点击查看免费下载相关推荐在 JavaScript 中建模复杂对象的高级关系构建一个 ActiveRecord 风格的 belongsTo / hasOne / hasMany 关系系统在 JavaScript 中建模复杂对象的高级关系构建一个 ActiveRecord 风格的 belongsTo / hasOne / hasMany 关系系教程文档30-seconds-of-code 系列用 JavaScript 为复杂对象实现 ActiveRecord 式 Scope 与计算属性缓存30 seconds of code 系列用 JavaScript 为复杂对象实现 ActiveRecord 式 Scope 与计算属性缓存 本文是 30 s教程文档30 Seconds of Code 实践用 JavaScript 在内存中建模复杂对象集合仿 ActiveRecord 的 Model 与 RecordSet30 Seconds of Code 实践用 JavaScript 在内存中建模复杂对象集合仿 ActiveRecord 的 Model 与 RecordS教程文档创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考