zoukankan      html  css  js  c++  java
  • enyo官方开发入门教程翻译一Tutorials之Part1:Enyo(转载)

    写在前面

    这篇翻译转载自:http://benluo.tumblr.com/post/16805078591/enyo-2-0。我自己也翻译了一下这一部分,后来在学习的过程中发现了“偶尔思考”的这篇翻译,对比一下发现人家比我翻译的通顺易懂多了,于是直接把他翻译的教程转过来了。

    Enyo 2.0 教程

    Enyo 2.0 教程

    欢迎来到 Enyo 2.0!

          如果你在看本教程,你可能希望看到 Enyo 能怎样帮助你开发网络软件。 我们就用几步来演示如何开发简单的应用,该应用显示不同的 Twitter 搜索结果。

    空白板

         作为开始,我们需要在你的桌面创建一个应用的文件夹。这个文件夹将保存你应用的 HTML 文件, 应用的 JavaScript 代码和该应用需要的其它东西。

         进入上述文件夹后,把ZIP 文件中的 enyo-2.0 文件夹复制过来。 我们将在 HTML 文件中从这个文件夹中引用最小化后的 JS 源代码和 CSS 文件。

    创建 app.html 并从这些内容开始

    <!DOCTYPE html>
    <html>
    
    <head>
      <title>My Twitter Search</title>
    
      <link rel=stylesheet href="http://enyojs.com/enyo-2.0b/enyo.css">
      <script src="http://enyojs.com/enyo-2.0b/enyo.js"></script>
    </head>
    
    <body>
    </body>
    
    </html>
    
    

         这是一个最小的 Enyo 应用。它是一个空的 HTML 文档,该文档把基本的 Enyo JavaScript 和 风格表引入其中。有标题,但没有实质的内容,没有调用 Enyo 代码。稍后会修复那些,但现在让这个 空白页面成为我们的游戏场。如果你在家,可以打开 JavaScript 终端并在其中直接输入下面的代码。

    它在哪

         在 Enyo 里所有 API,方法和对象保存在主 “enyo” 对象中,它像本框架的名字空间 一样运作。总之,在 enyo 中你不会看到像 $() 这样的定义。这能使代码 更像动词,同时它也可以限制 Enyo 代码和你自己的代码或别的框架之间冲突的数量。 可能扩展 enyo 名字空间的库,尤其是从 enyojs.com 站来的库,通常会加入 enyo 对象, 有时是子空间,如 enyo.dom

    核心 Enyo 代码由四个主要功能域:

    • 对象模型
    • HTML 渲染
    • 组件和事件分发
    • 异步和 Ajax 对象

    我们在创建应用时逐一介绍。

    创建你的 DOM

          Enyo 核心提供了一个叫 enyo.Control的 kind。 这是一个构造块,它用来制造所有网页上显示的部件和内容。

          enyo.Control 是 enyo 代码中用 enyo.kind() 方法定义的一个 kind,创建一个新的控制,你像创建者一样调用它并带着一个对象,该对象包含新实例的属性。 让我们从创建一个控制开始,它仅输出一段蓝色的 “Hello, World”。

    var control = new enyo.Control({
      name: "helloworld",
      tag: 'p',
      content: 'Hello, World!',
      style: 'color: black'
    });
    

          运行这段代码后,在 control 变量中有一个 enyo.Control 对象, 但什么也没有加入到 HTML 中。Enyo 有两个办法让控制可见。首先是 .write() 方法。这引发该控制在一个字符串中渲染自己的内容,然后把 它用document.write() 输出至 DOM。当页面载入时调用控制时有用。 输出应该像

    <p style="color: blue;" id="helloworld">Hello, World!</p>
    

         另外一个经常用到的方法是 .renderInto(domNode). 它把控制生成的 HTML 输出替代现有 DOM 中的元素。Enyo 应用通常的模式是用定义一个 大控制,它是整个继承了控制的应用,然后在 document.body 中渲染,并替代所有本 页载入的 HTML.

    让 control 在 document.body 中渲染作为本例的结束。

    control.renderInto(document.body);
    

    Enyo 特别实例化了控制在 document.body 中渲染的动作,并在新元素中自动添加了 ‘enyo-fit’ 类。

         你可能注意到生成的 HTML 包含了基于控制中 name 属性 的 id。 Enyo 跟踪它生成的名字哪怕你在应用中的不同地方有两个名字相同的控制,当在文档 里渲染时它们有唯一的 ID。由于这个原因,获取控制的 DOM 节点用 .hasNode() 方法,而不是用 ID 查找。

         假设我们想生成许多 “Hello, World” 控制,人可能希望定义自己的 kind。 可以像这样完成:

    enyo.kind({
      name: "HelloWorld",
      kind: enyo.Control,
      tag: 'p',
      content: 'Hello, World!',
      style: 'color: blue'
    });
    

    我们一但运行完这个,我们可以说

    var control2 = new HelloWorld({});
    

    如果我们希望重载控制的内容,我们也可以说

    var control3 = new HelloWorld({
      content: "Hello, Everyone!"
    });
    

    并渲染它

    control3.renderInto(document.body);
    

    或者在生成后调用下面方法改变它

    control3.setContent("Goodbye, World!");
    

         你会注意到如果你已经把控制渲染在页面内,调用 .setContent() 将 用改变的内容重新渲染。

    如果你还记得 enyo.Object,对象中任何公开的属性有自动生成的 get属性 和 set属性 方法。调用 .setContent() 会触发调用控制的 .contentChanged() 方法,那个代码会看本控制是不是已经在 DOM 中渲染了,如果是, 它调用 .render() 来刷新所有东西。

    这对其他属性也有用。你可以调用

    control3.setStyle("color: red");
    

         内容应该不变,但式样属性变了。对于式样改变,你更希望用像 .addClass() 和.applyStyle() 这样的方法,这些方法 能聪明地调整属性而不影响别的代码作出的改变。我们可以用以下方法产生同样 的效果

    control3.applyStyle("color", "black");
    

    生成 Tweet 控制

         为了显示 Twitter 搜索结果,我们需要对象的两个不同的 kind。第一,需要一个 显示控件,它用来把 tweet 的原始数据在网页上精美地显示出来。为了这个,我们生 成一个从 enyo.Control 继承的新对象。

    控制不单渲染自己的内容。还可以其它控制的母体,创建 DOM 元素的整个谱系。 例如,我们能生成一个控制来容纳头和有几个列表项的无序列表。

    var list = new enyo.Control({
      tag: "div",
      components: [
        { tag: "p", content: "The Additive Primary Colors" },
        { tag: "ul", components: [
          { tag: "li", name: "red", content: "red" },
          { tag: "li", name: "green", content: "green" },
          { tag: "li", name: "blue", content: "blue" } ] } ]
    });
    list.renderInto(document.body);
    

         它能工作是因为 enyo.Control 从 enyo.Component 继承而来。Components 能像 主机一样容纳整个谱系。在 components 列出的所有组件基于名字属性 加入到以 $ 命名的特殊哈希表。这样,如果我们希望红色列表项用红色 显示,我们可以写

    list.$.red.applyStyle("color", "red");
    list.$.green.applyStyle("color", "green");
    list.$.blue.applyStyle("color", "blue");
    
    

         它会变化。在本代码中红色项的父是无序列表,但它的所有者是顶级 div 控制。

         请注意,如果你想修改 list 的内容属性,什么事都不会发生。 作为控制,拥有子类比拥有内容有更高的优先级,所以当控制渲染时,只能 得到子类的内容,而不是自己本身的内容。

         更具体点,让我们看看用 Twitter 的 API 得到 tweet 列表时,Twitter 把什么 数据传给了过来。当你用 JSON 界面,你搜索的 tweet 以对象数组的形式传回来。我 们可以忽略几乎所有属性,但我们希望留意一点点:名字,头像和文本。

          在 HTML 里渲染的简单方法是生成一个有边框的 div,内部有间隙,在里面把头像 图标浮动向右对齐,然后粗体显示用户名,用普通字体显示文本。例如:

    <div style="border: 2px; padding: 10px; margin: 10px; min-height: 50px">
    <img src="http://twitter.com/imgs/a.png" 
        style="50px; height:50px; float: left; padding-right: 10px">
    <b>handle:</b> <span>tweet text</span>
    </div>
    

    渲染成这样

         让我们生成一个叫 TWeet 的新 kind,该 kind 会渲染这个内容。我们把这三个 数据项当成发布属性暴露出来。这会使我们拥有自动生成的 setter 方法。

         在下面的代码中,你将看到我们用与属性和组件同样的名字,那些属性会影响。这 没问题,因为属性直接在创建的对象中,而组件在 $ 哈希表中。

         在我们的组件定义中,我们依赖 enyo.Control 中缺省设置的 kind。如果你不指定 组件的 kind,它假定是 enyo.Control。缺省是一个属性,当你定义 kind 时可以设置 该属性;它不必要和你自己一致。例如:你可以用这个方法生成一个列表控件,该控件 所有的子控制都是列表元素类型。

         作为该方法的一部分,我们也重载了 .create() 方法和所有属性要调 用的更改方法。这是一个非常普通的带属性的组件。由于所有这此在预渲染时已经改变 了,实际调用是很轻量级的。如果你在控制渲染后再改变所有属性,那么控制会在每 次调用后重新渲染。重载从 this.inherited(arguments) 调用开始是非常重要的,因为这可以保证所有继承的创建代码工作。

    enyo.kind({
      name: "Tweet",
      kind: enyo.Control,
      tag: "div",
      style: "border-style: solid; border- 2px; " +
             "padding: 10px; margin: 10px; min-height: 50px",
    
      published: {
        icon: "",
        handle: "",
        text: ""
      },
    
      components: [
        { tag: "img", name: "icon",
          style: " 50px; height: 50px; float: left; padding-right: 10px" },
        { tag: "b", name: "handle" },
        { tag: "span", name: "text" }
      ],
    
      create: function() {
        this.inherited(arguments);
        this.iconChanged();
        this.handleChanged();
        this.textChanged();
      },
    
      iconChanged: function() {
        this.$.icon.setAttribute("src", this.icon);
      },
    
      handleChanged: function() {
        this.$.handle.setContent(this.handle + ": ");
      },
    
      textChanged: function() {
        this.$.text.setContent(this.text);
      }
    });
    

         如果我用以下代码创建一个 tweet 对象

    var t = new Tweet({
      icon: "touchhead_sq_normal.jpg",
      handle: "unwiredben", 
      text: "This is my tweet"});
    
    

         然后在一个空白页的 document.body 或 <div> 中渲染,

    t.renderInto(document.body);
    

    我得到的输出如下

    Rendering of tweet from unwiredben

    生成列表,检查它两次

          我们甚至可以生成 Control 对象,用该对象保存所有 Tweets,然后用 `.createComponent()’ 生成新的 Tweet 对象并用该控制作为所有者,然后把这个 Tweet 对象作为子类添加进来。见

    var l = new enyo.Control;
    l.createComponent({
      kind: Tweet,
      icon: "touchhead_sq_normal.jpg",
      handle: "unwiredben", 
      text: "First tweet"});
    l.createComponent({
      kind: Tweet,
      icon: "touchhead_sq_normal.jpg",
      handle: "unwiredben", 
      text: "Second tweet"});
    l.createComponent({
      kind: Tweet,
      icon: "touchhead_sq_normal.jpg",
      handle: "unwiredben", 
      text: "Third tweet"});
    l.renderInto(document.body);
    

         我们没有包括这些 Tweet 的名字,enyo 会基于 kind 名生成名字。如果你看 l.$ 数组,你会看到名字为”tweet”, “tweet2”, 和 “tweet3”的项目。如果你想删除第二 项,你可以写成

    l.$.tweet2.destroy();
    

         该命令同时销毁第二个 tweet 对象并让它的所有者控制从它的列表中删除。如果你 再一次调用 .createComponent() 来添加一个新的 tweet,它的名字是 “tweet4”,因为名字不会重用。

         Enyo 没有提供对组件进行重新排序的 API。你总在列表的最后添加。如果你要生成 一个控制,它嵌入在列表的UI上或下,你需要在中间嵌入另一个控制来对增加或删除的 项目定位。

    发现事件

         目前为止,不和用户交互的静态内容看着不错。但这不是应用。为了和用户的输入 交互,我们需要管理事件。

         在 HTML DOM 事件模型之上 Enyo 提供了自己的事件抽象层。原因是它支持在控制 和组件之间的路由事件,并更好地管理同步事件,这些同步事件是把类似鼠标事件与触 摸事件进行抽象。

         让我们从生成应用 kind 开始,该 kind 将保持用于 button 和 div 的简单控制。我们使按钮在 div 容器中添加新的 Tweet。

    enyo.kind({
      name: "TweetApp",
      kind: enyo.Control,
      components: [
        { tag: "button", content: "Add Tweet", ontap: "addTweet" },
        { tag: "div", name: "tweetList" }
      ],
    
      nextTweetNumber: 1,
      addTweet: function(inSource, inEvent) {
        this.createComponent({
          kind: Tweet,
          container: this.$.tweetList,
          icon: "touchhead_sq_normal.jpg",
          handle: "unwiredben", 
          text: "A new tweet! #" + this.nextTweetNumber
        });
        ++this.nextTweetNumber;
        this.$.tweetList.render();
      }
    });
    var tweetApp = new TweetApp();
    tweetApp.renderInto(document.body)
    

         和旧的 kind 定义比较,它有两个新东西。首先,在定义 addTweet 方法前, 我们也定义了一个内部属性 nextTweetNumber。因为它不是 published 数组,所以没有 set/get 方法。每一个 TweetApp 的实例都取得自己的值复本。

         其次,在 components 定义按钮时,我们定义了 ontap 属性。 这是在 enyo 中与事件句柄钩连的方法;你基于事件类型命名一个属性,然后把它的值设为 调用该方法的组件所有者的方法名。Enyo 自动钩连大部分常见 DOM 事件;我们可以用 “onclick” 替代,但我们选择 “tap” 事件,因为框架从鼠标或者触摸事件来同步。

          The addTweet 句柄遵从标准模型。第一个参数是事件源的对象。第二个参数是事件 对象,根据事件会有其它附加信息的参数。然而由于只有一个控制与 addTweet 钩 连在一起,同时 tap 是一个简单的事件,我们忽略了参数并仅做我们的动作,向列表中添加新的 Tweet

         在 .createComponent() 中要留意一个新的属性 container。 它让我们可以生成一个 Tweet 对象,该对象的所有者是顶级应用对象,但这个对像被加入作为 tweetList 物理部分的组件。

         不像在控制中更改属性,添加新组件不会引起控制立即重新渲染。你必需调用.render() 方法使你的内部输出。当你添加多个组件时,你不需要做太多的工作,因为这个过程 是经过优化的,不是一遍一遍渲染页面,而是等到整组就绪后才渲染。

    保持它独立

         我们定义了几个 kind,把它们放在源文件中会有用。Enyo 应用通常把一组 .js 文件捆绑进一个包。 也就是说你在应用中增加了一个 package.js 文件,该文件列出了所有 JS 和 CSS 源文件,并在 enyo.depends() 方法中调用。

    如果我们把 Tweet kind 定义保存在 Tweet.js, TweetApp kind 存在 TweetApp.js 中,那么我们的 pacakge.js 文件像这样

    enyo.depends(
      "Tweet.js",
      "TweetApp.js"
    );
    

         你注意到多个源文件可以作为 enyo.depends 的参数。顺序很关键,因为 JS 代码是 根据这个顺序载入和运行的。你需要先列出独立的 kind,然后是有依赖关系的 kind。

         你也可以加上目录名;在这种情况下 Enyo 装载器试着在该目录下打开 package.js 并运行其中的条目。这个动作连续递归进行一阵子,就像 Enyo 核心代码那样,一个 顶级 package.js 文件里列出了所有框架代码所在的子目录。

         对于正在开发的应用,HTML 文件中经常有两个 <script> 标签 一个载入最小化后的 enyo.js,别一个载入你本地的 package.js 文件,该文件引用你 的所有源代码,比如:

      <link rel=stylesheet href="http://enyojs.com/enyo-2.0b/enyo.css">
    
      <script src="http://enyojs.com/enyo-2.0b/enyo.js"></script>
      <script src="package.js"></script>
    

    针对布署,Enyo 最小化脚本知道如何检查 package.js 文件,并把与你应用有关的 JS 和 CSS 最小化。

    与 Twitter 对话

         现在我们已经明白如何显示 tweet 和如何管理用户输入,让我用与远程服务器对话 的方式作为总结。Twitter 是一个非常流行的服务,它有稳定的 API,让我们试试。

         为了作 Twitter 搜索,我们需要一个字段来输入搜索条目,一个按钮来启动搜索 还有一些代码来管理生成需求,解释结果以及生成 tweet 列表并显示。

          对于许多网站 API,它不可能直接调用 API。因为浏览器的安全特性限制了 服务器通过 XMLHttpRequest 与分享同一源的应用交流。然而随着 API 变得越来越 流行,出现了一个衍生的范式来克服这种局限。网页总可以从别的服务器装载 <script> 标签,这样有的程序员意识到脚本只要设置变量 或调用一个函数就可以动态生成 JavaScript 代码。这个范式命名为 JSONP 或者 JavaScript Object Notation with Padding.

          Enyo 有内置的方法来包装 XmlHttpRequests (enyo.Xhr 和 enyo.Ajax 代码),但没有 JSONP 相关的方法。为了克服这个缺陷 我写了自己的一个 kind,它是 enyo.Async 子类。该 kind 来做 JSONP 需求。我们在这用新的 enyo.JsonpRequest。代码如下作为参考,但你可以完全 不懂。

    /**
    A specialized form of enyo.Async that is used for making JSONP requests to a
    remote server. This differs from normal XmlHTTPRequest calls because the
    external resource is loaded using a <script> tag. This allows bypassing same-
    domain rules that normally apply to XHR since the browser will load scripts
    from any address.
    */
    
    enyo.kind({
        name: "enyo.JsonpRequest",
        kind: enyo.Async,
    
        published: {
            //* The URL for the service.
            url: "",
            /**
                name of the argument that holds the callback name. For example, the
                Twitter search API uses "callback" as the parameter to hold the
                name of the called function.  We will automatically add this to
                the encoded arguments.
            */
            callbackName: ""
        },
    
        statics: {
            // counter to allow creating unique names for each JSONP request
            nextCallbackID: 0,
    
            // For the tested logic around adding a <script> tag at runtime, see the
            // discussion at the URL below: 
            // http://www.jspatterns.com/the-ridiculous-case-of-adding-a-script-element/
            addScriptElement: function(src) {
                var script = document.createElement('script');
                script.src = src;
                var first = document.getElementsByTagName('script')[0];
                first.parentNode.insertBefore(script, first);
                return script;      
            },
    
            removeElement: function(elem) {
                elem.parentNode.removeChild(elem);
            }
        },
    
        //* @protected
        constructor: function(inParams) {
            enyo.mixin(this, inParams);
            this.inherited(arguments);
        },
    
        //* @public
    
        //* starts the JSONP request
        go: function(inParams) {
            this.startTimer();
            this.jsonp(inParams);
            return this;
        },
    
        //* @protected
    
      // for a string version of inParams, we follow the convention of
      // replacing the string "=?" with the callback name.  For the more
      // common case of inParams being an object, we'll add a argument named
      // using the callbackName published property.
      jsonp: function(inParams) {
            var callbackFunctionName = "ENYO_JSONP_CALLBACK_" + 
                (enyo.JsonpRequest.nextCallbackID++);
    
            var parts = this.url.split("?");
            var uri = parts.shift() || "";
            var args = parts.join("?").split("&");
            var body;
    
            if (enyo.isString(inParams)) {
                body = inParams.replace("=?", "=" + callbackFunctionName);
            }
            else {
                var params = enyo.mixin({}, inParams);
                params[this.callbackName] = callbackFunctionName;
                body = enyo.Ajax.objectToQuery(params);
            }
    
            args.push(body);    
            var url = [uri, args.join("&")].join("?");
            var script = enyo.JsonpRequest.addScriptElement(url);
            window[callbackFunctionName] = enyo.bind(this, this.respond);
    
            // setup cleanup handlers for JSONP completion and failure
            var cleanup = function() {
                enyo.JsonpRequest.removeElement(script);
                window[callbackFunctionName] = null;
            };
            this.response(cleanup);
            this.error(cleanup);
    
        }
    });
    

          这有一个用 Twitter 搜索 API 的例子。如果你抓 URLhttp://search.twitter.com/search.json?q=enyo&callback=cb, 你会得到一些如下的结构:

    cb({
      // header about request
      "results": [
        {
            // tweet data
        },
        {
            // tweet data
        },
        ...
      ]
    });
    

         它们调用返回的普通 JSON 结果,它们的搜索 API 返回没有回调参数,但现在它用 前面有 cb( 和后面有 ); 的 padding 包装了起来。关于 该参数的细节及所有其它的支持信息,请看 Twitter 搜索 API 文档.

    (小窍门: JSONLint 网站善长把难以阅读的 JSON 文件转成容易 阅读的数据。它会抱怨 JSONP 结果,但仍会管理去重新格式化所有需要看的内容。我 用它的输出来判定返回了什么域。)

          除了结果的头部分,没有什么我们需要注意的。有一些字段指示当前最大 tweet ID 和如何要求下一页的数据,但现在我们只关心保存在 “结果” 里的 tweet。

         升级应用到新的 kind,TwitterSearchApp,我们将定义一个输入域,一个按钮 以及容器。

    enyo.kind({
      name: "TwitterSearchApp",
      kind: enyo.Control,
      components: [
        { tag: "input", name: "searchTerm" },
        { tag: "button", content: "Search", ontap: "search" },
        { tag: "div", name: "tweetList" }
      ],
    
      addTweet: function(inResult) {
        this.createComponent({
          kind: Tweet,
          container: this.$.tweetList,
          icon: inResult.profile_image_url,
          handle: inResult.from_user,
          text: inResult.text
        });
      },
    
      search: function() {
        var searchTerm = this.$.searchTerm.hasNode().value;
        var request = new enyo.JsonpRequest({
            url: "http://search.twitter.com/search.json",
            callbackName: "callback"
          });
    
        request.response(enyo.bind(this, "processSearchResults"));
        request.go({ q: searchTerm });
      },
    
      processSearchResults: function(inRequest, inResponse) {
        if (!inResponse) return;
        this.$.tweetList.destroyClientControls();
        enyo.forEach(inResponse.results, this.addTweet, this);
        this.$.tweetList.render();
      }
    });
    
    var twitterSearchApp = new TwitterSearchApp();
    twitterSearchApp.renderInto(document.body);
    

         search() 方法从输入字段获得搜索要求,创建 JsonpRequest 对象 ,建立后续的回调函数,然后让请求运行。我们在这用了一个新的 Enyo 方法 enyo.bind(); 它可以让你在绑定的 “this” 上下文中运行某方法,它 像 ECMAScript 5 中针对函数的 .bind() 方法。Enyo 版的 .bind() 方法在提供的对象 中查找属性有优势,而且它可以在旧的不支持新调用的 Javascript 引擎内工作。

          当 JSONP 代码调用 .processSearchResults() 方法时,我们将显示 结果。当我们得到 Twitter 传回的结果时发生。我们首先销毁所有现存的 tweet,这 些 tweet 是显示上次搜索的结果,然后用 enyo.forEach() 方法在数组 中对每一项重复调用 .addTweet() 。

          在新的 addTweet 方法中,我们从 Twitter API 结果字段映射到现有的 Tweet UI 控制。from_user 字段映射 handletext 字段映射 textprofile_image_url 字段对应icon。不像前一个版本,我们不立即调用.render() 而是等添加完所有结果后再调用。

    总结

          我已经在这放了一个可以运行的最终版 http://enyojs.com/tutorial/search.html. 然而还有很多事要做。输入合法性检测和错误管理没有。在点击输入 到得到输入这段时间它没有显示任何东西。如果你在手机或其它慢速 internet 接入的 情况下,显示等待控件或类似的东西来表明请求已经提交会更有用。

    结果的式样可以更好点。我们以嵌入的式样,你也可以用外部式样表的 CSS 类的方 式。参见 enyo.Control() 文档中有关添加和删除 CSS 类的方法和 属性。

    需要更多 Enyo 的帮助,查看从 Enyo 网站 得到的例子,以及在 开发者论坛里提问。谢谢你一直看到这并享受用 Enyo 编程!

  • 相关阅读:
    关于Servelet在Tomcat中执行的原理
    类变量被final修饰编译时结果确定变为宏
    本地无法连接远程服务器(Host is not allowed to connect to this MySQL server)解决办法(Windows)
    leetcode_227. 基本计算器 II
    leetcode_150. 逆波兰表达式求值
    leetcode_145. 二叉树的后序遍历
    leetcode_144. 二叉树的前序遍历
    leetcode_94. 二叉树的中序遍历
    leetcode_71. 简化路径
    1598. 文件夹操作日志搜集器
  • 原文地址:https://www.cnblogs.com/waimai/p/2849653.html
Copyright © 2011-2022 走看看