开发一个组件
A-Frame的 实体-组件框架 中的组件是在可以被混合、匹配和组合到实体上的JavaScript模块(module),来构建外观、行为和功能。我们可以使用JS注册组件并使用声明式语法在DOM中使用它。组件是可配置,可重用和可分享的。A-Frame应用程序的代码应该尽可能放在组件里面。
Image by Ruben Mueller from vrjump.de
注意组件必须被定义在 <a-scene>
之前,如下所示:
<html> <head> <script src="foo-component.js"></script> </head> <body> <script> // Or inline before the <a-scene>. AFRAME.registerComponent('bar', { // ... }); </script> <a-scene> </a-scene> </body></html> |
下面我们将使用一些小例子来讨论如何编写组件,会演示数据流、API和用法。对于大型组件的开发,请参见hello-world
组件例子
让我们从最基本的组件开始,有个大致的概念。在该组件的实体被通过.init()
处理器来添加到场景中时,将打印一条简单的hello world日志信息。
使用 AFRAME.registerComponent
来注册组件
组件使用 AFRAME.registerComponent()
方法来注册。我们传递组件名字,作为组件在DOM中的HTML属性名称使用。 然后我们传递 组件定义,这是一个包含方法和属性的JS对象。其中,我们可以定义生命周期处理器方法。其中之一是初始化 .init()
,当组件第一次被添加进实体中时被执行。
In the example below, we just have our .init()
handler log a simple message.
AFRAME.registerComponent('hello-world', { init: function () { console.log('Hello, World!'); }}); |
在HTML中使用组件
然后我们可以在HTML中以声明式使用这个 hello-world
组件为一个标签。
<a-scene> <a-entity hello-world></a-entity></a-scene> |
当实体被添加和初始化后,它将初始化我们的这个 hello-world
组件。关于组件的奇妙之处在于它们是只有实体准备好后才能调用。我们不用担心等待场景或实体被构建好,它就是能工作了!如果我们检查命令行,Hello, World!
在场景开始运行且实体被添加后将被打印一次。
在JS中使用组件
除了在HTML中静态声明,另外一种设置组件的方式是使用JS .setAttribute()
接口来动态编程。场景(scene)元素也一样可以接受组件,让我们使用程序来在场景中设置组件:
document.querySelector('a-scene').setAttribute('hello-world', ''); |
例子:log
组件
和 hello-world
组件类似,下面我们来制作一个 log
组件。这个例子仍然只是输出命令行日志,但我们将能够使用 console.log
来输出更多信息。我们的 log
组件将打印任何传入的字符串。我们将了解如何通过模式定义可配置属性来将数据传递给组件。
用模式(Schema)定义属性
schema 定义了其组件的 属性(properties)。 作为类比,如果我们把组件看作是一个函数,那么组件的属性就类似于函数参数。属性有名称(如果组件有多于一个属性,一个默认值,和一个属性类型。物业类型定义 如果数据作为字符串传递(即从DOM中),如何解析数据。
对于log
组件,我们通过schema
定义一个message
属性类型。这个message
属性类型将有一个string
属性类型以及一个缺省字符串值Hello, World!
:
AFRAME.registerComponent('log', { schema: { message: {type: 'string', default: 'Hello, World!'} }, // ...}); |
从生命周期处理器(Lifecycle Handler)中使用属性数据
这个 string
属性类型不会对传入的数据做任何处理,将直接透传给生命周期处理器。现在让我们 console.log
那个 message
属性类型。和 hello-world
组件类似,我们编写一个 .init()
处理器,但这一次我们不是输出一个硬编码的字符串,而是通过 this.data
来访问。所以我们将打印this.data.message
!
AFRAME.registerComponent('log', { schema: { message: {type: 'string', default: 'Hello, World!'} }, init: function () { console.log(this.data.message); }}); |
然后在HTML中,我们可以添加该组件给一个实体。对于一个多属性组件,语法和行内css样式类似(属性的键值对,通过:
分开,并且属性之间通过;
分开):
<a-scene> <a-entity log="message: Hello, Metaverse!"></a-entity></a-scene> |
属性更新
到目前为止,我们使用的 .init()
处理器仅在组件生命周期开始属性初始化的时候调用一次。但是组件通常需要动态更新其属性。我们可以使用 .update()
处理器来负责属性更新。
Lifecycle method handlers. Image by Ruben Mueller from vrjump.de
为此,我们将使 log
组件只在它的实体发送一个事件的打印日志。首先,我们将添加一个 事件(event)
属性类型来指定组件应该侦听什么事件。
// ...schema: { event: {type: 'string', default: ''}, message: {type: 'string', default: 'Hello, World!'},},// ... |
然后我们把 .init()
处理器中的代码挪到 .update()
处理器中。.update()
处理器在添加组件时紧接着 .init()
后面被调用。有时候,我们把主要的逻辑都放在 .update()
处理器里,这样我们可以一次性执行初始化以及更新以避免代码重复。
我们想做的是 添加一个事件处理器 用来在记录消息之前侦听事件。如果没有指定 event
的属性类型,我们将只是打印该消息日志:
AFRAME.registerComponent('log', { schema: { event: {type: 'string', default: ''}, message: {type: 'string', default: 'Hello, World!'} }, update: function () { var data = this.data; // Component property values. var el = this.el; // Reference to the component's entity. if (data.event) { // This will log the `message` when the entity emits the `event`. el.addEventListener(data.event, function () { console.log(data.message); }); } else { // `event` not specified, just log the message. console.log(data.message); } }}); |
现在我们已经添加了事件侦听器属性,让我们来处理一个实际的属性更新。当event
属性类型变更时(比如,调用.setAttribute()
设置属性后),我们需要删除之前的事件侦听器,并添加一个新的。
但是为了删除一个事件侦听器,我们需要一个指向它的引用。因此我们在添加事件处理器时首先把该函数存放在this.eventHandlerFn
上。当我们通过this
给组件附加属性时,它们将在整个其他生命周期处理器中保持可用。
AFRAME.registerComponent('log', { schema: { event: {type: 'string', default: ''}, message: {type: 'string', default: 'Hello, World!'} }, init: function () { // Closure to access fresh `this.data` from event handler context. var self = this; // .init() is a good place to set up initial state and variables. // Store a reference to the handler so we can later remove it. this.eventHandlerFn = function () { console.log(self.data.message); }; }, update: function () { var data = this.data; var el = this.el; if (data.event) { el.addEventListener(data.event, this.eventHandlerFn); } else { console.log(data.message); } }}); |
现在我们把事件处理器保存起来了。我们可以在 event
属性类型更新时随时来删除它。为了检查event
属性类型发生变化,我们把this.data
和通过.update()
处理器提供的oldData
参数进行对比:
AFRAME.registerComponent('log', { schema: { event: {type: 'string', default: ''}, message: {type: 'string', default: 'Hello, World!'} }, init: function () { var self = this; this.eventHandlerFn = function () { console.log(self.data.message); }; }, update: function (oldData) { var data = this.data; var el = this.el; // `event` updated. Remove the previous event listener if it exists. if (oldData.event && data.event !== oldData.event) { el.removeEventListener(oldData.event, this.eventHandlerFn); } if (data.event) { el.addEventListener(data.event, this.eventHandlerFn); } else { console.log(data.message); } }}); |
现在让我们用更新事件监听器来测试我们的组件。这是我们的场景:
<a-scene> <a-entity log="event: anEvent; message: Hello, Metaverse!"></a-entity></a-scene> |
Let’s have our entity emit the event to test it out:
var el = document.querySelector('a-entity');el.emit('anEvent');// >> "Hello, Metaverse!" |
Now let’s update our event to test the .update()
handler:
var el = document.querySelector('a-entity');el.setAttribute('log', {event: 'anotherEvent', message: 'Hello, new event!'});el.emit('anotherEvent');// >> "Hello, new event!" |
处理组件删除情况
现在让我们来处理从实体中去除组件的情况(也就是,.removeAttribute('log')
)。我们可以实现一个 .remove()
处理器,该函数将在组件被删除时被调用。对于 log
组件,我们将删除附加到实体的组件的任何事件侦听器:
AFRAME.registerComponent('log', { schema: { event: {type: 'string', default: ''}, message: {type: 'string', default: 'Hello, World!'} }, init: function () { var self = this; this.eventHandlerFn = function () { console.log(self.data.message); }; }, update: function (oldData) { var data = this.data; var el = this.el; if (oldData.event && data.event !== oldData.event) { el.removeEventListener(oldData.event, this.eventHandlerFn); } if (data.event) { el.addEventListener(data.event, this.eventHandlerFn); } else { console.log(data.message); } }, /** * Handle component removal. */ remove: function () { var data = this.data; var el = this.el; // Remove event listener. if (data.event) { el.removeEventListener(data.event, this.eventHandlerFn); } }}); |
现在让我们测试下删除处理程序,让我们删除组件并检查发射事件不再做任何事情:
<a-scene> <a-entity log="event: anEvent; message: Hello, Metaverse!"></a-entity></a-scene> |
var el = document.querySelector('a-entity');el.removeAttribute('log');el.emit('anEvent');// >> Nothing should be logged... |
允许一个组件的多个实例
然我们允许有多个 log
组件被附加给同一个实体。为此,我们启用 multiple instancing with the .multiple
标识。 设置该标识为 true
:
AFRAME.registerComponent('log', { schema: { event: {type: 'string', default: ''}, message: {type: 'string', default: 'Hello, World!'} }, multiple: true, // ...}); |
多实例组件的属性名称语法形式如下:<COMPONENTNAME>__<ID>
,带有ID后缀的双下划线。ID可以是任选值。例如,在HTML中:
<a-scene> <a-entity log__helloworld="message: Hello, World!" log__metaverse="message: Hello, Metaverse!"></a-entity></a-scene> |
或者在JS中:
var el = document.querySelector('a-entity');el.setAttribute('log__helloworld', {message: 'Hello, World!'});el.setAttribute('log__metaverse', {message: 'Hello, Metaverse!'}); |
在组件中,如果想得到不同的实例属性,可以使用 this.id
和 this.attrName
。比如给定log__helloworld
,this.id
将是 helloworld
而 this.attrName
将是完整的 log__helloworld
。
到此为止,我们已经完成构建了一个基础的 log
组件!
例子: box
组件
举个稍微大一点的例子,让我们看看如何通过使用three.js来编写组件添加3D对象并改变场景图。为此,我们将开发一个基础的 box
组件来创建一个包含几何模型和材料的box网孔(mesh)。
Image by Ruben Mueller from vrjump.de
注意: 这只是 Hello, World!
组件的一个3D等价版本。 A-Frame 提供 geometry 和 material 组件来创建实用的box模型。
模式(Schema)和API
让我们从模式开始。模式定义了组件的API。 我们将通过属性使得width
, height
, depth
, 和 color
可配置。width
, height
, 和 depth
将是数据类型(也就是浮点数),缺省值为1米,color
类型将是一个颜色类型(也就是一个字符串),缺省值为gray:
AFRAME.registerComponent('box', { schema: { width: {type: 'number', default: 1}, height: {type: 'number', default: 1}, depth: {type: 'number', default: 1}, color: {type: 'color', default: '#AAA'} }}); |
稍后我们通过HTML使用这个组件时,语法看起来会像下面这样:
<a-scene> <a-entity box="width: 0.5; height: 0.25; depth: 1; color: orange" position="0 0 -5"></a-entity></a-scene> |
创建Box Mesh
我们从.init()
来开始创建three.js box mesh,并且稍后我们将使用 .update()
处理器来处理所有的属性更新。要在three.js中创建一个box,可以使用盒子缓存模型THREE.BoxBufferGeometry
,以及标准网孔材料 THREE.MeshStandardMaterial
,以及一个网孔对象 THREE.Mesh
。然后,我们使用 .setObject3D(name, object)
方法在实体上设置该网孔对象将其添加到three.js场景图中:
AFRAME.registerComponent('box', { schema: { width: {type: 'number', default: 1}, height: {type: 'number', default: 1}, depth: {type: 'number', default: 1}, color: {type: 'color', default: '#AAA'} }, /** * Initial creation and setting of the mesh. */ init: function () { var data = this.data; var el = this.el; // Create geometry. this.geometry = new THREE.BoxBufferGeometry(data.width, data.height, data.depth); // Create material. this.material = new THREE.MeshStandardMaterial({color: data.color}); // Create mesh. this.mesh = new THREE.Mesh(this.geometry, this.material); // Set mesh on entity. el.setObject3D('mesh', this.mesh); }}); |
现在让我们处理更新。如果几何模型相关的属性(i.e., width
, height
, depth
) 发生变更, 我们将只要重新创建该几何模型。如果是材料相关属性(i.e., color
)发生变化,我们只需要更新相应的材料即可。要访问mesh并修改它,我们使用方法.getObject3D('mesh')
。
AFRAME.registerComponent('box', { schema: { width: {type: 'number', default: 1}, height: {type: 'number', default: 1}, depth: {type: 'number', default: 1}, color: {type: 'color', default: '#AAA'} }, init: function () { var data = this.data; var el = this.el; this.geometry = new THREE.BoxBufferGeometry(data.width, data.height, data.depth); this.material = new THREE.MeshStandardMaterial({color: data.color}); this.mesh = new THREE.Mesh(this.geometry, this.material); el.setObject3D('mesh', this.mesh); }, /** * Update the mesh in response to property updates. */ update: function (oldData) { var data = this.data; var el = this.el; // If `oldData` is empty, then this means we're in the initialization process. // No need to update. if (Object.keys(oldData).length === 0) { return; } // Geometry-related properties changed. Update the geometry. if (data.width !== oldData.width || data.height !== oldData.height || data.depth !== oldData.depth) { el.getObject3D('mesh').geometry = new THREE.BoxBufferGeometry(data.width, data.height, data.depth); } // Material-related properties changed. Update the material. if (data.color !== oldData.color) { el.getObject3D('mesh').material.color = data.color; } }}); |
删除Box Mesh
最后,我们将处理实体或组件的删除事件。这里,我们想把mesh从场景中删除。我们可以使用.remove()
处理器和.removeObject3D(name)
方法:
AFRAME.registerComponent('box', { // ... remove: function () { this.el.removeObject3D('mesh'); }}); |
这样我们就实现了一个基本的three.js box
组件!实际使用上,所有使用three.js实现的组件都可以被封装为声明式的A-Frame组件。
例子: follow
组件
现在我们来开发一个 follow
组件,用来告诉一个实体去跟随另外一个实体。 这将演示 .tick()
处理器的用法,该函数将在每次帧渲染循环中添加一个连续运行的行为。这也将演示实体之间的关系。
模式(Schema)和API
首先,我们需要一个 目标(target)
属性来指定要跟随哪个实体。A-Frame 有一个selector
属性类型来处理这个问题,允许我们传入一个查询选择器并返回一个实体元素。我们也将添加一个 speed
属性 (单位是:米/秒) 来指定实体跟随的速度。
AFRAME.registerComponent('follow', { schema: { target: {type: 'selector'}, speed: {type: 'number'} }}); |
创建一个辅助矢量类(Helper Vector)
由于 .tick()
处理器将在每个渲染帧被调用(比如, 90 FPS),我们需要确保其性能,因此要避免在每次tick中创建不需要的对象,比如THREE.Vector3
对象,这将导致垃圾回收问题。我们将使用同一个THREE.Vector3
对象来执行若干矢量操作,我们将在.init()
处理器中一次性创建,然后在后面复用它:
AFRAME.registerComponent('follow', { schema: { target: {type: 'selector'}, speed: {type: 'number'} }, init: function () { this.directionVec3 = new THREE.Vector3(); }}); |
用.tick()
处理器定义一个行为
Now we’ll write the .tick()
handler so the component continuously moves the entity towards its target at the desired speed. A-Frame passes in the global scene uptime as time
and time since the last frame as timeDelta
into the tick()
handler, in milliseconds. We can use the timeDelta
to calculate how far the entity should travel towards the target this frame, given the speed.
To calculate the direction the entity should head in, we subtract the entity’s position vector from the target entity’s direction vector. We have access to the entities’ three.js objects via .object3D
, and from there the position vector .position
. We store the direction vector in the this.directionVec3
we previously allocated in the init()
handler.
Then we factor in the distance to go, the desired speed, and how much time has passed since the last frame to find the appropriate vector to add to the entity’s position. We translate the entity with .setAttribute
and in the next frame, the .tick()
handler will be run again.
The full .tick()
handler is below. .tick()
is great because it allows an easy way to hook into the render loop without actually having a reference to the render loop. We just have to define a method. Follow along below with the code comments:
AFRAME.registerComponent('follow', { schema: { target: {type: 'selector'}, speed: {type: 'number'} }, init: function () { this.directionVec3 = new THREE.Vector3(); }, tick: function (time, timeDelta) { var directionVec3 = this.directionVec3; // Grab position vectors (THREE.Vector3) from the entities' three.js objects. var targetPosition = this.data.target.object3D.position; var currentPosition = this.el.object3D.position; // Subtract the vectors to get the direction the entity should head in. directionVec3.copy(targetPosition).sub(currentPosition); // Calculate the distance. var distance = directionVec3.length(); // Don't go any closer if a close proximity has been reached. if (distance < 1) { return; } // Scale the direction vector's magnitude down to match the speed. var factor = this.data.speed / distance; ['x', 'y', 'z'].forEach(function (axis) { directionVec3[axis] *= factor * (timeDelta / 1000); }); // Translate the entity in the direction towards the target. this.el.setAttribute('position', { x: currentPosition.x + directionVec3.x, y: currentPosition.y + directionVec3.y, z: currentPosition.z + directionVec3.z }); }}); |
通过社区组件来学习
A-Frame社区中有大量的组件,大部分都是开放源代码在GitHub上。一种很有效的学习方法是阅读这些组件的源代码。看看它们是如何构建的,以及用来做什么。这里有几个地方可以看下:
- A-Frame 注册表(Registry) - 精选社区组件
- A-Frame 核心组件 - A-Frame基础组件的源代码
- awesome-aframe 组件 - 社区组件大清单
- A-Painter 组件 - A-Painter应用级的组件。
组件发布
实践中的许多组件将是特定于应用程序或是一次性组件。但是,如果您编写了一个对社区有用的组件,并且足够通用来在其他应用程序中工作,您可以通过the A-Frame Registry 和 awesome-aframe
发布到社区生态系统中!
对于组件模板,我们建议使用 angle
。angle
是一个A-Frame的命令行工具,其功能之一是建立一个组件模板,发布到GitHub和npm,并保持和生态系统中的所有其他组件一致。要安装该模板可执行如下命令:
npm install -g angle && angle initcomponent |
initcomponent
会询问一些基本信息如组件名称来进行模板设置。开发好组件后,同时也要编写示例和文档,接着 发送一个提交请求 到 A-Frame 注册表(Registry) 来获得推荐。记得遵循 Registry 指南,我们会做一个快速的代码复查。通过后社区将能够使用您的组件,同时其他开发者也可能会为你的组件开发做出贡献!