Magento 2 KnockoutJS 集成

原文地址

虽然 KnockoutJS 作为 MVVM 框架(model,view,view model),但是PHP开发人员会发现model部分有点瘦。KnockoutJS 本身没有 data storage 的概念,像许多现代js框架一样,it was designed to work best with a service only backend(设计为和 service only 的后端一起工作). i.e. KnockoutJS’s “Model” is some other framework making AJAX requests to populate view model values.

另外一件可能会让你意外的事情是,KnockoutJS 并不是一个“full stack”框架(and to its credit, doesn’t bill itself as such)

KnockoutJS 没有规定你如何在项目中使用它,也没有规定你怎么组织代码。(虽然从文档可以看出KnockoutJS的团队成员是RequireJS的粉丝)。

这对类似Magento这样的服务器端PHP框架提出了一个有趣的挑战。要使用KnockoutJS,不仅得围绕着它搭建一定程度的js“脚手架”,而且 Magento 2本身可不是一个service only的框架。Magento 2 的全新API在解决后一个问题上取得了进展,但是后端开发人员还是需要建立将业务数据对象注入 KnockoutJS 的脚手架。

今天我们来深入Magento 2 的 KnockoutJS 集成问题。通过本教程,你将明白 Magento 2 是如何应用 KnockoutJS 绑定的,以及Magento 2 是如何初始化他的自定义绑定的。你还将明白 Magento 是如何修改核心 KnockoutJS 的某些行为的,为什么要这样做,以及这些修改带给你的模块和应用的可能性。

本篇教程是 Alan 的 Magento 2 高级js概念系列文章中的一篇,如果你在下文遇到不理解的概念,可以翻看一下该文章的前几篇。

Creating a Magento Module

我们得创建一个Pulsestorm KnockoutTutorial模块,并且可以通过http://magento.example.com/pulsestorm_knockouttutorial/链接看到app/code/Pulsestorm/KnockoutTutorial/view/frontend/templates/content.phtml模板的渲染效果。

Alan 没有提供示例代码,你可以自己创建,也可以去博主的 github 仓库下载。示例代码下载地址

$ php bin/magento module:enable Pulsestorm_KnockoutTutorial
$ php bin/magento setup:upgrade

RequireJS Initialization

在我们之前的文章以及KnockoutJS官方教程中,KnockoutJS的初始化时很简单的事情。

object = SomeViewModelConstructor();
ko.applyBindings(object);

上面的做法只是为了教程讲解。然而,如果你将所有的 view model 逻辑,自定义绑定,组件都放在一个代码块中的话,KnockoutJS 将很快变得不好管理了。

Magento 的核心团队创建了一个Magento_Ui/js/lib/ko/initialize RequireJS 模块。只要将它作为依赖模块,就会执行KnockoutJS的初始化。你可以像下面这样使用它。

requirejs(['Magento_Ui/js/lib/ko/initialize'], function(){
    //your program here
});

要注意的是这个 RequireJS 模块没有返回值,将他作为依赖模块唯一的目的就是进行 KnockoutJS 的初始化。你在实际环境中见到他,可能会感到困惑。举例来说,看下面的代码:

#File: vendor/magento/module-ui/view/base/web/js/core/app.js
define([
    './renderer/types',
    './renderer/layout',
    'Magento_Ui/js/lib/ko/initialize'
], function (types, layout) {
    'use strict';

    return function (data) {
        types.set(data.types);
        layout(data.components);
    };
});

声明了三个 RequireJS 依赖

#File: vendor/magento/module-ui/view/base/web/js/core/app.js
[
'./renderer/types',
'./renderer/layout',
'Magento_Ui/js/lib/ko/initialize'
]

但是回调函数中却只有两个参数

#File: vendor/magento/module-ui/view/base/web/js/core/app.js
function (types, layout) {
    //...
}

我还不清楚这种做法是否明智,或者是否违反了RequireJS的精神。也许都有。

不管怎么说,你用这个模块,Magento 就会初始化 KnockoutJS 了。RequireJS 会在第一次加载模块时就缓存他们,所以之后加载的模块不会被重复载入。

KnockoutJS Initialization

我们来看看Magento_Ui/js/lib/ko/initialize模块的源代码

#File: vendor/magento/module-ui/view/base/web/js/lib/ko/initialize.js
define([
    'ko',
    './template/engine',
    'knockoutjs/knockout-repeat',
    'knockoutjs/knockout-fast-foreach',
    'knockoutjs/knockout-es5',
    './bind/scope',
    './bind/staticChecked',
    './bind/datepicker',
    './bind/outer_click',
    './bind/keyboard',
    './bind/optgroup',
    './bind/fadeVisible',
    './bind/mage-init',
    './bind/after-render',
    './bind/i18n',
    './bind/collapsible',
    './bind/autoselect',
    './extender/observable_array',
    './extender/bound-nodes'
], function (ko, templateEngine) {
    'use strict';

    ko.setTemplateEngine(templateEngine);
    ko.applyBindings();
});

我们看到这个模块相对简单,但还是包含了19个其他模块。要说清每个模块做什么,已经超出本篇的范围了。只讲重要的。

ko模块实际上是knockoutjs/knockout模块

vendor/magento/module-theme/view/base/requirejs-config.js
11:            "ko": "knockoutjs/knockout",
12:            "knockout": "knockoutjs/knockout"

knockoutjs/knockout模块实际上就是 KnockoutJS 库。knockoutjs/knockout-repeat,knockoutjs/knockout-fast-foreach, and
knockoutjs/knockout-es5 是 KnockoutJS community extras(社区插件)这些都不是正规的 RequireJS 模块。

./bind/*开始的模块是 Magento 的 KnockoutJS 自定义绑定。他们是正经的 RequireJS 模块,不过他们实际上没有返回。这些脚本给全局的ko对象添加绑定。我们下面会讨论scope 绑定。如果你对绑定实现的细节感兴趣,可以去这里看看,期待官方文档快点出来。

这两个extender模块是Magento 对 KnockoutJS 功能扩展的核心模块。

./template/engine模块返回了一个自定义版本的 KnockoutJS 模板引擎。这是我们下面第一个要讨论的。

Magento KnockoutJS Templates

KnockoutJS 系统中,模板是一些事先写好的 DOM/KnockoutJS 代码,可以通过id来引用他们。这些代码块以下面的形式添加到页面中:

<script type="text/html" id="my_template">
    <h1 data-bind="text:title"></h1>
</script>

这是个很强大的功能,但是提出了一个问题——对于服务器端的框架来说,如何获取正确的模板并渲染到页面上呢?如何确保不用重复创建模板呢?KnockoutJS 的解决方案是使用组件绑定配合RequireJS,但是这意味着你的模板绑定在特定的view model 对象上。

Magento 的核心工程师需要一种更好的办法来加载 KnockoutJS 模板。—— 他们使用Magento_Ui/js/lib/ko/template/engine模块的引擎替换掉了 KnockoutJS 原先的模板引擎。

#File: vendor/magento/module-ui/view/base/web/js/lib/ko/initialize.js
define([
    'ko',
    './template/engine',
    //...
], function (ko, templateEngine) {
    'use strict';
    //...
    ko.setTemplateEngine(templateEngine);
    //...
});

如果我们看一看Magento_Ui/js/lib/ko/template/engine这个模块。

#File: vendor/magento/module-ui/view/base/web/js/lib/ko/template/engine.js
/**
 * Copyright © 2016 Magento. All rights reserved.
 * See COPYING.txt for license details.
 */
define([
    'ko',
    './observable_source',
    './renderer'
], function (ko, Source, Renderer) {
    'use strict';

    var RemoteTemplateEngine,
        NativeTemplateEngine = ko.nativeTemplateEngine,
        sources = {};

    //...

    RemoteTemplateEngine.prototype = new NativeTemplateEngine;


    //...
    RemoteTemplateEngine.prototype.makeTemplateSource = function (template)
    {
        //...
    }
    //...

    return new RemoteTemplateEngine;
});

Magento 创建了一个新的对象,该对象用原型法继承了 KnockoutJS 的引擎,然后修改了一些方法增加自定的行为。If you’re not up on your javascript internals, this means Magento copies the stock KnockoutJS template system, changes it a bit, and then swaps its new template engine in for the stock one.

实现的细节超过了本篇的范围,不过最终的结果是KnockoutJS 引擎可以通过 URL 链接从Magento模板中获取模板。

如果这样说没有让你搞懂,那么来个例子。向content.phtml文件中加入以下代码:

#File: app/code/Pulsestorm/KnockoutTutorial/view/frontend/templates/content.phtml
<div data-bind="template:'Pulsestorm_KnockoutTutorial/hello'"></div>

这里我们用了模板绑定,并且把Pulsestorm_KnockoutTutorial/hello字符串传递给他。现在如果你刷新页面的话(记得清空缓存),你将会在浏览器 console 中看到如下错误信息:

>Get http://127.0.0.1/pub/static/frontend/Magento/luma/en_US/Pulsestorm_KnockoutTutorial/template/hello.html 404 (Not Found)

Magento 用我们传递给他的字符串(Pulsestorm_KnockoutTutorial/hello)构造一个 URL ,注意看这个构造出的URL和字符串各部分的联系。

让我们添加如下文件:

#File: app/code/Pulsestorm/KnockoutTutorial/view/frontend/web/template/hello.html
<p data-bind="style:{fontSize:'24px'}">Hello World</p>

重新载入页面,你会看到 Magento 已经加载了该模板并且应用了KnockoutJS绑定。

这项功能使我们不再需要使用<script type="text/html">,有助于模板复用。(This feature allows us to avoid littering our HTML page with <script type="text/html"> tags whenever we need a new template, and encourages template reuse between UI and UX features.)

No View Model

回到initialize.js模块,Magento 设置好模板引擎之后,调用了 KnockoutJS 的applyBindings 方法。这个调用开始了当前页面的渲染。看下代码,立马发现一件事情。

#File: vendor/magento/module-ui/view/base/web/js/lib/ko/initialize.js
ko.setTemplateEngine(templateEngine);
ko.applyBindings();

Magento 调用applyBindings没有传递任何 view model。虽然这是一个有效的 KnockoutJS 调用——让 KnockoutJS 应用绑定,但没有数据或view model 逻辑,这么做看起来一点意义也没有。What is a view without data going to be good for?

在标准的 KnockoutJS 系统中,这样做确实一点意义也没有。要理解这一步Magento在做什么,我就得回到 KnockoutJS 的初始化。

#File: vendor/magento/module-ui/view/base/web/js/lib/ko/initialize.js
define([
    //...
    './bind/scope',
    //...
],

Magento 的 KnockoutJS 团队创建了一个名为scope的自定义绑定。下面是一scope绑定应用的例子,代码是 Magento 2 主页中用到的。

<li class="greet welcome" data-bind="scope: 'customer'">
    <span data-bind="text: customer().fullname ? $t('Welcome, %1!').replace('%1', customer().fullname) : 'Default welcome msg!'"></span>
</li>

当你向下面这样调用scope的时候,

data-bind="scope: 'customer'"

Magento 将会对该节点及其子节点应用customer view model

你可能会想——见鬼,customer view model 是什么玩意儿?

如果你查看主页的源代码,再往下看一点(源代码在105行),你应该会看到如下标签。

<script type="text/x-magento-init">
{
    "*": {
        "Magento_Ui/js/core/app": {
            "components": {
                "customer": {
                    "component": "Magento_Customer/js/view/customer"
                }
            }
        }
    }
}
</script>

我们本系列的第一篇文章讲过,Magento 遇到text/x-magento-initscript 标签,属性为*,它将:

  1. 初始化Magento_Ui/js/core/appRequireJS 模块。
  2. 调用Magento_Ui/js/core/app模块的返回方法,并将后面的数据对象传递给他。

Magento_Ui/js/core/app模块 instantiates KnockoutJS view models to use with the scope attribute.(实例化 scope 自定义绑定要用的view model)。这是怎么实现的超出本篇文章的范围了,不过总的来说,Magento will instantiate a new javascript object for each individual RequireJS module configured as a component, and that new object becomes the view model.

下面我们用x-magento-init举个例子,Magento查找components键,该键对应的是一个键值对。

"customer": {
    "component": "Magento_Customer/js/view/customer"
}

customer键,Magneto 会执行类似下面的代码:

//gross over simplification
var ViewModelConstructor = requirejs('Magento_Customer/js/view/customer');
var viewModel = new ViewModelConstructor;
viewModelRegistry.save('customer', viewModel);

如果某些组件对象有额外的数据

"customer": {
    "component": "Magento_Customer/js/view/customer",
    "extra_data":"something"
}

Magento 会将这些数据一并放入view model

一旦上述代码执行完毕,view model registry 就会拥有一个名为customer的view model。Magento 对data-bind="scope: 'customer'"应用的绑定就是这个view model。

如果我们查看scope自定义绑定的实现代码的话,

#File: vendor/magento/module-ui/view/base/web/js/lib/ko/bind/scope.js
define([
    'ko',
    'uiRegistry',
    'jquery',
    'mage/translate'
], function (ko, registry, $) {
    'use strict';

    //...
        update: function (el, valueAccessor, allBindings, viewModel, bindingContext) {
            var component = valueAccessor(),
                apply = applyComponents.bind(this, el, bindingContext);

            if (typeof component === 'string') {
                registry.get(component, apply);
            } else if (typeof component === 'function') {
                component(apply);
            }
        }
    //...

});

registry.get(component, apply);行从 view model registry 处获得那个view model,之后的代码实际上是应用该 view model

#File: vendor/magento/module-ui/view/base/web/js/lib/ko/bind/scope.js

//the component variable is our viewModel
function applyComponents(el, bindingContext, component) {
    component = bindingContext.createChildContext(component);

    ko.utils.extend(component, {
        $t: i18n
    });

    ko.utils.arrayForEach(el.childNodes, ko.cleanNode);

    ko.applyBindingsToDescendants(component, el);
}

registry变量来自于uiRegistry模块,该模块实际上是Magento_Ui/js/lib/registry/registry

vendor/magento/module-ui/view/base/requirejs-config.js
17:            uiRegistry:     'Magento_Ui/js/lib/registry/registry',

如果感觉脑子不够用了,别担心。如果你想看看某个scope绑定中可用的数据,下面的debugging 代码会很有帮助。

<li class="greet welcome" data-bind="scope: 'customer'">
    <pre data-bind="text: ko.toJSON($data, null, 2)"></pre>
    <!-- ... -->
</li>

如果你对 Magento如何创建 view model 的真实实现细节感兴趣的话(上面的简化伪代码不能满足你的好奇心),你可以从Magento_Ui/js/core/app模块下手。

#File: vendor/magento/module-ui/view/base/web/js/core/app.js
define([
    './renderer/types',
    './renderer/layout',
    'Magento_Ui/js/lib/ko/initialize'
], function (types, layout) {
    'use strict';

    return function (data) {
        types.set(data.types);
        layout(data.components);
    };
});

这个模块依赖Magento_Ui/js/core/renderer/layout模块,正是这个模块创建了 view model,并将他们添加到view model registry 中。

#File: vendor/magento/module-ui/view/base/web/js/core/renderer/layout.js

你可去上面的文件研究 view model 是怎么创建出来的。

A component by Any Other Name

One sticky wicket in all this is the word component. scope绑定加上x-magento-init is basically a different take on the native KnockoutJS component system.

Magento 使用了和 KnockoutJS 相同的component这一术语,他们实际上不是一回事情,这下简直开启了一个混乱的新世界。即使是Magento官方文档,看起来也没有说清楚什么是component什么不是。Such is life on a large software team where the left hand doesn’t know what the right hand is doing — and the rest of the body is freaking out about a third hand growing out of its back.(哈哈,翻不出味道,自己领会一下,挺幽默的!)

当你和同事讨论这些特性或者是在社区提问时,区分 KnockoutJS components 和Magento components 是很重要的哦。

Changes in the 2.1 Release Candidate

今天的总结,我们要讨论一下 Magento 2.1 版本的一些改变。从概念上讲,系统仍然是相同的,只是细节有一点变化。

第一,KnockoutJS 的初始化现在发生在Magento_Ui/js/lib/knockout/bootstrapRequireJS 模块。

#File: vendor/magento/module-ui/view/base/web/js/lib/knockout/bootstrap.js
define([
    'ko',
    './template/engine',
    'knockoutjs/knockout-es5',
    './bindings/bootstrap',
    './extender/observable_array',
    './extender/bound-nodes',
    'domReady!'
], function (ko, templateEngine) {
    'use strict';

    ko.uid = 0;

    ko.setTemplateEngine(templateEngine);
    ko.applyBindings();
});

Magento 核心开发团队将所有的 binding loading 移到Magento_Ui/js/lib/knockout/bindings/bootstrap模块,该模块位于:

#File: vendor/magento/module-ui/view/base/web/js/lib/knockout/bindings/bootstrap.js

最后,the “Magento Javascript Component” returned by Magento_Ui/js/core/app has a changed method signature that includes a merge parameter, and the arguments to the layout function make it clear layout‘s signature has changed as well.

#File: vendor/magento/module-ui/view/base/web/js/core/app.js
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 的 js 模块和框架在频繁变动中,不同于PHP代码,Magento 的 RequireJS 模块没有@api标识来表示稳定性。

Unless you absolutely need to, it’s probably best to steer clear of dynamically changing the behavior of these core modules, and keep your own javascript as separate as possible。

下载

实例代码下载

发表评论

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