Magento 2: Simplest UI Knockout Component

在上篇文章中,我们通过 <preference> 的方式创建了一个最简单的 Magento 2 UI Component。如果你阅读完了整篇内容,我打赌你会对没有介绍 javascript 感到失望。今天我们将弥补上次的缺憾。

像之前一样,确保系统运行在 developer 模式,还有我们是在 Magento 2.1.1 上做的实验,不过其中涉及到的概念是所有版本都通用的。强烈建议在阅读本文前先阅读上篇文章,不过如果你想直接开动的话,我们在 GitHub 上放了上篇的源代码

An App for Knockout.js View Models

Alan 的 Magento 2 for PHP MVC Developers系列文章的第二篇 Magento 2: Serving Frontend Files 讲解了 Magento 2 中是如何使用前端文件的。在 Magento 2: Advanced Javascript系列中讲到了 x-magento-init 和 Magento 2 中 Knockoug.js 实现的基础知识。2016 年 7 月份我们提到了 Magento 2 的 Knockout.js 的 template 中一些奇怪的标签(Magento’s KnockoutJS Templates aren’t KnockoutJS Templates)。如果你没有阅读过前面的系列文章,那么光看刚刚的这篇,你也能获益颇多。不过如果你理不清的话,这些文章可能会给你帮助。

在上篇文章结束后,我们有了一个 Pulsestorm_SimpleUiComponent模块。查看源代码,看到的类似下面这样:

<script type="text/x-magento-init">
{
    "*":
    {
        "Magento_Ui/js/core/app":
        {
            "types":
            {
                "dataSource": [],
                "pulsestorm_simple":
                {
                    "extends": "pulsestorm_simple"
                },
                "html_content":
                {
                    "component": "Magento_Ui\/js\/form\/components\/html",
                    "extends": "pulsestorm_simple"
                }
            },
            "components":
            {
                "pulsestorm_simple":
                {
                    "children":
                    {
                        "pulsestorm_simple":
                        {
                            "type": "pulsestorm_simple",
                            "name": "pulsestorm_simple"
                        },
                        "pulsestorm_simple_data_source":
                        {
                            "type": "dataSource",
                            "name": "pulsestorm_simple_data_source",
                            "dataScope": "pulsestorm_simple",
                            "config":
                            {
                                "data": [
                                {
                                    "foo": "baz"
                                }],
                                "params":
                                {
                                    "namespace": "pulsestorm_simple"
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}
</script>

x-magento-init 将会把这个 JSON 对象传递给 Magento_Ui/js/core/app 模块。我们来看看这个模块的源代码。(Magento 2 and RequireJS (翻译)

/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */
define([
    './renderer/types',
    './renderer/layout',
    '../lib/knockout/bootstrap'
], function (types, layout) {
    'use strict';

    return function (data, merge) {
        types.set(data.types);
        layout(data.components, undefined, true, merge);
    };
});

这个看似简单的程序实际上是 Magento 的 UI 组件系统中最重要的 javascript 文件之一。这里的代码(准确地说是 Magento_Ui/js/core/renderer/typesMagento_Ui/js/core/renderer/layout 模块中的代码)负责创建和注册所有的 Knockout.js 的 view model 构造函数。

view model 的概念如果不是很熟悉的话,可以参考 Knockout.js 的官方 tutorials 以及 Alan 的 Magento 2: Advanced Javascript

view model 构造函数的注册是我们不太熟悉的概念。本篇文章中,我们不会完整地介绍这个概念,我们现在只要知道 Magento_Ui/js/core/app 运行后,Magento 将向全局注册表(global registry)中加入许多 view model 的构造对象。

要理解这点,最快的办法是找个 grid 页面我们来看下 registry 是个啥。Products > Catalog 到产品列表页,然后打开 debug 工具(chrome 中在 View -> Developer -> Javascript Console

The uiRegistry

Magento 将所有的 Knockout.js 的 view model constructor 注册到 uiRegistry 模块返回的对象中。我对 javascript 的AMD 规范没有深入了解,所以我不确定这种注册到全局的做法是不是最佳实践,不过既然 Magento 这么做了,那么我们最好接受这点,然后继续我们的教程。

在 RequireJS map 中,uiRegistry 是一个 key

// File: vendor/magento/module-ui/view/base/requirejs-config.js
var config = {
    paths: {
        'ui/template': 'Magento_Ui/templates'
    },
    map: {
        '*': {
            uiElement:      'Magento_Ui/js/lib/core/element/element',
            uiCollection:   'Magento_Ui/js/lib/core/collection',
            uiComponent:    'Magento_Ui/js/lib/core/collection',
            uiClass:        'Magento_Ui/js/lib/core/class',
            uiEvents:       'Magento_Ui/js/lib/core/events',
            uiRegistry:     'Magento_Ui/js/lib/registry/registry',
            uiLayout:       'Magento_Ui/js/core/renderer/layout',
            buttonAdapter:  'Magento_Ui/js/form/button-adapter'
        }
    }
};

uiRegistry 对应的模块是 Magento_Ui/js/lib/registry/registry,实际位置在 vendor/magento/module-ui/view/base/web/js/lib/registry/registry.js 。这个 registry 对象类似于字典或 hash map。你可以通过 registry 的 set 方法设置一个值,使用 get 方法去取得值。让我们来实验下。在浏览器的 debugger 中加载 uiRegistry 模块。

> reg = requirejs('uiRegistry');
Registry {}

在 debugger 中看不到 uiRegistry 中的属性值。Magento 核心团队让它的属性是司有的。只能通过 get 方法来取得已注册的值。让我们来实验下:

> reg.get('product_listing.product_listing');
UiClass {_super: undefined, ignoreTmpls: Object, _requesetd: Object,
    containers: Array[0], exports: Object…}

这里我们取得了一个名为 product_listing.product_listing 的 Knockout.js view model 。

uiRegistry 和普通的字典或 hash map 不同的地方在于 get 方法是支持查询语法的。你可以在他的定义文件 vendor/magento/module-ui/view/base/web/js/lib/registry/registry.js 中看到该查询语言的简要说明。我们将跳过这里,你只要知道他支持回调,这样我们可以取得 registry 中的所有对象。下面试一下:

> reg.get(function(item){
    console.log(item.name);
    console.log(item);
});
//long list of view model constructor and names snipped

回调查询让我们可以变相地查看司有属性,一窥注册地所有 view models

Configuring a View Model Constructor

产品列表页包含了大量地 view models ,不过我们还是回到较简单的 model 中吧。进入 System -> Other Settings -> Hello Simple UI Component 然后再次尝试下面的:

> reg = requirejs('uiRegistry');
reg.get(function(item){
    console.log(item.name);
    console.log(item);
});
undefined

我们啥也没拿到。UI Component system 并没有自动地创建 view models 。我们需要通过一个 RequireJS module 来配置我们的 UI component ,让该 RequireJS module 返回一个 view model constructor 。

首先,我们在 definition.xml 中加入配置节点

#File: app/code/Pulsestorm/SimpleUiComponent/view/base/ui_component/etc/definition.xml
<components xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_definition.xsd">
    <pulsestorm_simple class="Pulsestorm\SimpleUiComponent\Component\Simple">
        <argument name="data" xsi:type="array">
            <!-- ... -->
            <item name="config" xsi:type="array">
                <item name="component" xsi:type="string">Pulsestorm_SimpleUiComponent/js/pulsestorm_simple_component</item>
            </item>
        </argument>
    </pulsestorm_simple>
</components>

清空缓存后重新载入页面,然后我们查看源代码,会看到下面这样的 JSON

"components": {
    "pulsestorm_simple": {
        "children": {
            "pulsestorm_simple": {
                "type": "pulsestorm_simple",
                "name": "pulsestorm_simple",
                "config": {
                    "component": "Pulsestorm_SimpleUiComponent\/js\/pulsestorm_simple_component"
                }
            },
//...

下面我们创建下面的文件,清空缓存后刷新页面。

//File: app/code/Pulsestorm/SimpleUiComponent/view/adminhtml/web/js/pulsestorm_simple_component.js
define([], function(){
    console.log("Called");
});

然后我们会在 console 中看到如下错误:

Called
Uncaught TypeError: Constr is not a constructor

有进展哦。Called 表明我们的模块被加载了。但是我们的模块没有返回一个 view model constructor 。下面我们来修复他。

//File: app/code/Pulsestorm/SimpleUiComponent/view/adminhtml/web/js/pulsestorm_simple_component.js
define(['uiElement'], function(Element){
    viewModelConstructor = Element.extend({
        defaults: {
            template: 'Pulsestorm_SimpleUiComponent/pulsestorm_simple_template'
        }
    });

    return viewModelConstructor;
});

这里我们导入 uiElement 模块,用它的 extend 方法创建一个新的对象,这个对象就是我们的 view model constructor ,然后我们返回这个对象。

uiElement 模块(Magento_Ui/js/lib/core/element/element)是为 UI Component 系统建造的自定义类的一部分。本文不能展开来讲了,不过他是基于 underscore JS 的,如果你好奇的话,可以看这里 Magento 2’s Base Javascript Class

上面 defaults 对象的 template 属性定义了我们的 view model 要用的 remote template (Magento 2: KnockoutJS Integration

Hooking up the View Model

下面咱们清空缓存刷新页面后,在浏览器 debugger 中输入下面的代码:

reg = requirejs('uiRegistry');
//hold your questions on pulsestorm_simple.pulsestorm_simple
//we'll get there in a second
viewModelConstructor = reg.get('pulsestorm_simple.pulsestorm_simple')

然后我们会看到一条返回的 veiw model

UiClass {_super: undefined, ignoreTmpls: Object, _requesetd: Object, containers: Array[0], exports: Object…}

下一步我们将把这个 view model 和我们的 HTML 页面的 DOM 节点联系起来。这里 Magento 自定义的 Knockout.js scope 绑定就要隆重登场了。修改你的 UI Component 的 XHTML 模板像下面这样:

<!-- File: app/code/Pulsestorm/SimpleUiComponent/view/adminhtml/ui_component/templates/different.xhtml -->
<?xml version="1.0" encoding="UTF-8"?>
<div>
    <h1>Hello Brave New World</h1>
    <div data-bind="scope: 'pulsestorm_simple.pulsestorm_simple'" class="entry-edit form-inline">
        <!-- ko template: getTemplate() --><!-- /ko -->
    </div>
</div>

这里我们做了两件事情。第一件,我们加了 data-bind="scope: 'pulsestorm_simple.pulsestorm_simple'" 这个属性,这个属性会调用 Magento Knockout.js scope 绑定。scope 绑定需要一个参数 (pulsestorm_simple.pulsestorm_simple)。Magento 会用这个参数去查找 uiRegistry 中的 view model ,然后使得这个 view model 成为所有内部节点的当前 Knockout.js veiw model。scope 数据绑定允许你在同一个页面的不同部分使用不同的 Knockout.js view model

第二件事情是我们放了一个类似于 Knockout.js 绑定标签的东东:<!-- ko template: getTemplate() --><!-- /ko -->这个东东将加载当前 view model 的 template 。getTemplate 方法来自于我们继承的类 uiElement

下面清空缓存并刷新页面,我们将看到下面的错误:

Unable to resolve the source file for ‘adminhtml/Magento/backend/enUS/PulsestormSimpleUiComponent/template/pulsestormsimpletemplate.html’ #0 /path/to/magento/vendor/magento/framework/App/StaticResource.php(97): Magento\Framework\View\Asset\File->getSourceFile() #1 /path/to/magento/vendor/magento/framework/App/Bootstrap.php(258): Magento\Framework\App\StaticResource->launch() #2 /path/to/magento/pub/static.php(13): Magento\Framework\App\Bootstrap->run(Object(Magento\Framework\App\StaticResource)) #3 {main}

因为我们刚刚配置了 template 但却没有创建他呀。下面咱们创建一下。

<!-- File: app/code/Pulsestorm/SimpleUiComponent/view/adminhtml/web/template/pulsestorm_simple_template.html -->
<h1>Rendered with Knockout.js</h1>

然后清空缓存刷新页面,你应该看到下面这样的:

恭喜你,你刚刚创建了一个基于 Knockout.js 的 Magento UI Component.

Using Knockout

当然了,花这么大力气就为了加载一个静态 HTML 也太小题大做了。要充分利用 Knockout.js 的优势,你需要在 RequireJS 模块中导入它。

举例说来,

<!-- File: app/code/Pulsestorm/SimpleUiComponent/view/adminhtml/web/template/pulsestorm_simple_template.html -->
<h1>Rendered with Knockout.js</h1>
<strong data-bind="text: message"></strong>
//File: vendor/magento//module-ui/view/base/web/js/core/app.js
define(['uiElement','ko'], function(Element, ko){
    viewModelConstructor = Element.extend({
        defaults: {
            template: 'Pulsestorm_SimpleUiComponent/pulsestorm_simple_template'
        },
        message: ko.observable("Hello Knockout.js!")
    });

    return viewModelConstructor;
});

这里,我们导入了 ko 模块。这个 ko 模块就相当于 Knockout.js 提供的全局 koMagento 2 KnockoutJS 集成)。我们还加了一个 message 属性,把这个属性设成 ko.observable 对象。这个就是 Knockout.js 的用法。然后刷新页面,你应该看到:

下面在浏览器 debugger 中,输入下面的代码

> reg = requirejs('uiRegistry');
> reg.get('pulsestorm_simple.pulsestorm_simple').message("Change Me");

然后立刻,我们的页面变成下面这样了:

Modern Javascript and the Browser Debugger

Magento 2 javascript(以及许多其他现代 javascript)调试的挑战之一就是跟踪哪些加载了哪些没有。再也不像以前那样,查看源代码查找 <script> 标签。

Google Chrome 的 debugger ,如果你要查找 RequireJS 的模块。那么可以去 Source tab

如果你要看 Knockout.js 的 remote template ,Network -> XHR 正是你要找的。

需要特别注意浏览器中使用的文件是不是最新的。Magento 有自己的缓存机制,还有 Magento 对前端文件设置了一些强势的 header (Magento 2: Serving Frontend Files)所以除了清空 Magento 缓存,有时候也有必要清理浏览器的缓存。

Why the Double Name

view model constructor 重复的名称可能会让你觉得困惑。

product_listing.product_listing
pulsestorm_simple.pulsestorm_simple

名称来自于 /ui_component/*.xml 文件名

<!-- File: app/code/Pulsestorm/SimpleUiComponent/view/adminhtml/ui_component/pulsestorm_simple.xml -->
<uiComponent name="pulsestorm_simple"/>

但是,通过上面的例子,我们依然不清楚为什么要用两次名称,这种格式意味着某种层次。这正是 UI Component 系统的特性,通过他我们将理解 Magento 自带的 listing 和 form components 是怎么回事。

首先,回到 definition.xml 文件,修改成下面这样:

#File: app/code/Pulsestorm/SimpleUiComponent/view/base/ui_component/etc/definition.xml
<components xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_definition.xsd">
    <pulsestorm_simple class="Pulsestorm\SimpleUiComponent\Component\Simple">
        <argument name="data" xsi:type="array">
            <item name="config" xsi:type="array">
                <!-- <item name="component" xsi:type="string">Pulsestorm_SimpleUiComponent/js/pulsestorm_simple_component</item> -->
                <item name="component" xsi:type="string">uiComponent</item>
            </item>
        </argument>
    </pulsestorm_simple>
</components>

这里我们将 Pulsestorm_SimpleUiComponent/js/pulsestorm_simple_component 替换成了 uiComponent ,这个模块就是 Magento_Ui/js/lib/core/collection

下面我们清除缓存刷新页面,这次我们的模板不会被加载咯。这说明,不同的 view model 不同的模板。

下面在浏览器 debugger 中输入下面的代码:

> reg = requirejs('uiRegistry');
> viewModelConstructor = reg.get('pulsestorm_simple.pulsestorm_simple')
> viewModelConstructor.getTemplate()
ui/collection

这个 ui/collection 对应的就是下面这个文件:

<!-- File: vendor/magento//module-ui/view/base/web/templates/collection.html -->
<!--
/**
 * Copyright © 2016 Magento. All rights reserved.
 * See COPYING.txt for license details.
 */
-->
<each args="data: elems, as: 'element'">
    <render if="hasTemplate()"/>
</each>

如果你对 Magento 2 的前端代码不熟悉,那么肯定会对这些标签感到困惑。这些标签是 Magento 2 扩展了 Knockout.js rendering engine 后的结果(Magento’s KnockoutJS Templates aren’t KnockoutJS Templates)。用 Knockout.js 的写法,就是下面这样:

<!-- ko foreach: {data: elems, as: 'element'} -->
    <!-- ko if: hasTemplate() --><!-- ko template: getTemplate() --><!-- /ko --><!-- /ko -->
<!-- /ko -->

然后在浏览器中输入下面的代码,我们看看 elems 是什么

> reg = requirejs('uiRegistry');
> viewModelConstructor = reg.get('pulsestorm_simple.pulsestorm_simple')
> viewModelConstructor.elems()
[]

是个空数组。那么我们怎么往里面加东西呢?通过 UI Component 配置。

<!-- File: app/code/Pulsestorm/SimpleUiComponent/view/adminhtml/ui_component/pulsestorm_simple.xml -->
<pulsestorm_simple xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
    <!--  ... -->
    <htmlContent name="first_ever_child">
        <argument name="block" xsi:type="object">Magento\Framework\View\Element\Text</argument>
        <argument name="data" xsi:type="array">
            <item name="config" xsi:type="array">
                <item name="component" xsi:type="string">Pulsestorm_SimpleUiComponent/js/pulsestorm_simple_component</item>
            </item>
        </argument>
    </htmlContent>
</pulsestorm_simple>

这里我们给 pulsestorm_simple.xml 加了一个 <htmlContent/> 子节点。这个 <htmlContent/> 是 Magento 提供的,不过这不重要,重要的是我们给这个节点配置了 Pulsestorm_SimpleUiComponent/js/pulsestorm_simple_component 组件。

清空缓存然后刷新页面,你又看到 Pulsestorm_SimpleUiComponent/js/pulsestorm_simple_component 组件加载的模板了。

有意思的是 uiRegistry

> reg = requirejs('uiRegistry');
> reg.get(function(item){
    console.log(item.name);
})
undefined
pulsestorm_simple.pulsestorm_simple
pulsestorm_simple.pulsestorm_simple.first_ever_child
undefined

这里我们看到了组件定义的层次结构。下面我们再到 UI Component XML 中加上另一个节点。

<!-- File: app/code/Pulsestorm/SimpleUiComponent/view/adminhtml/ui_component/pulsestorm_simple.xml -->
<pulsestorm_simple xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
    <!--  ... -->
    <htmlContent name="first_ever_child">
        <argument name="block" xsi:type="object">Magento\Framework\View\Element\Text</argument>
        <argument name="data" xsi:type="array">
            <item name="config" xsi:type="array">
                <item name="component" xsi:type="string">Pulsestorm_SimpleUiComponent/js/pulsestorm_simple_component</item>
            </item>
        </argument>
    </htmlContent>

    <htmlContent name="second_ever_child">
        <argument name="block" xsi:type="object">Magento\Framework\View\Element\Text</argument>
        <argument name="data" xsi:type="array">
            <item name="config" xsi:type="array">
                <item name="component" xsi:type="string">Pulsestorm_SimpleUiComponent/js/pulsestorm_simple_component</item>
            </item>
        </argument>
    </htmlContent>
</pulsestorm_simple>

然后清空缓存刷新页面,我们会看到那个模板被加载了两次。

然后咱们到浏览器 debugger 中看下:

> reg = requirejs('uiRegistry');
> reg.get(function(item){
    console.log(item.name);
})
pulsestorm_simple.pulsestorm_simple
pulsestorm_simple.pulsestorm_simple.first_ever_child
pulsestorm_simple.pulsestorm_simple.second_ever_child

你有很多方法可以用 UI Component system 去完成前端代码,不过最终 Magento 2 中的主要用法就是这样的。uiComponent/Magento_Ui/js/lib/core/collection 模块 collects and renders a series of Knockout.js view models

顶层的 UI Component 负责加载 XHTML 模板,但是如果他的 configuration 中设置了 uiComponent 的属性,并且他的 XHTML 中通过 scope 绑定调用该模块,那么他的子节点会被命名成这种树形的结构。

这一点有点难理解,的 configuration 中设置了 uiComponent 的属性,指的是:

#File: app/code/Pulsestorm/SimpleUiComponent/view/base/ui_component/etc/definition.xml
<components xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_definition.xsd">
    <pulsestorm_simple class="Pulsestorm\SimpleUiComponent\Component\Simple">
        <argument name="data" xsi:type="array">
            <item name="config" xsi:type="array">
                <!-- uiComponent 属性 -->
                <item name="component" xsi:type="string">uiComponent</item>
            </item>
        </argument>
    </pulsestorm_simple>
</components>

XHTML 中通过 scope 绑定调用该模块,指的是:

<!-- File: app/code/Pulsestorm/SimpleUiComponent/view/adminhtml/ui_component/templates/different.xhtml -->
<?xml version="1.0" encoding="UTF-8"?>
<div>
    <h1>Hello Brave New World</h1>
    <div data-bind="scope: 'pulsestorm_simple.pulsestorm_simple'" class="entry-edit form-inline">
        <!-- ko template: getTemplate() --><!-- /ko -->
    </div>
</div>

子节点会被命名成树形结构,指的是:

pulsestorm_simple.pulsestorm_simple
pulsestorm_simple.pulsestorm_simple.first_ever_child
pulsestorm_simple.pulsestorm_simple.second_ever_child

让人困惑的是,根节点也被注册成一个 view model constructor 了,这就是 pulsestorm_simple.pulsestorm_simple 的由来。

总结

经过这么一番捣鼓,你现在应该更深入了解 Magento 2 的神秘新系统 UI Component 了。不过,还有很多等着我们去探索。再下一篇文章中,我们将更深入地挖掘一下,看看 UI Components 怎么获得 <dataProvider> 中地数据,然后修订我们这次地模块,看看能不能不用 <preference/> 使用这个系统。

发表评论

电子邮件地址不会被公开。 必填项已用*标注