regular+stateman+require

一、regular

Regularjs是基于动态模板实现的用于构建数据驱动型组件的类库。
关键词:动态模板引擎,数据驱动,组件

  • 动态模板引擎:实现view是会随着数据变化的而变化
  • 数据驱动:强制将你的业务逻辑抽象为数据(状态)的变化-->双向数据绑定实现方式移步 双向数据绑定方法
  • 组件: 组件化开发,将一些公用的东西组件化。比如:城市选择,图片上传,公用头部等等。

二、 模板语法

ES5 表达式:rgl模板几乎完整的按ES5的规范实现了表达式, 你可以几乎按以往js的经验来使用你的表达式,这点你在其它数据驱动的框架如vuejs或avalon中是享受不到的, 当然并不是说必须要在模板里去声明复杂的表达式,只是提供了可能性 。
举个例子,下列表达式在regularjs中都是合法的:

{100 + ‘b’}
{user? ‘login’: ‘logout’}
{parseInt(22.1)}

注意几个要点

  1. 表达式中的this指向组件本身, 所以你可以通过this.xx来调用组件的某个方法。
  2. 数据根路径从component.data开始, 即user 其实获取的是
    component.data.user。
  3. rgl不支持自增、自减(++,--)以及位操作符& |等。
  4. rgl不支持正则表达式的字面量。
  5. rgl开放了部分JS内建供使用:
    Array Date JSON Math NaN RegExp Object String
    decodeURI decodeURIComponent encodeURI encodeURIComponent
    parseFloat parseInt

**1. 插值 {Expression} **

  • 文本插值 <div>{content}</div>
  • 属性插值 .modal-{klass}

2. 规则Rule
严格来说,在插值之外的语法功能, 都由RULE , RULE的语法是{#NAME }

  • list
    list指令用来遍历某个sequence来循环的处理某些重复性的结构(vue中的v-for,angular中的ng-repeat)
    在每次循环都会创建一个临时变量代表当前下标
    //regular
    {#list list as item}
         <div>{item_index}{item.name}</div>
    {/list}

    //vue
    <li v-show="list" v-for ="(index,item) in list" v-touch="touchMove" >
       {{index}} {{item.name}}
    </li>
  • if/else/elseif
    与其它模板引擎(如freemarker,handlebars)一样, regular也提供了if,elseif,else等语法元素提供对逻辑控制的支持
{#if condition} //这个表达式结果会被强制转换为Boolean值
  ...
{#elseif condition2}
  ...
{#else}
  ...
{/if}

** 优点**:使用if控制属性,根据判断依据,指令、属性或事件会被添加或移除

// control the attribute 
<div {#if active == 'home'} data-home {/if}>Home</div>
// control the event 
<a {#if current < last} on-click={this.next()} {/if}>Next</a>
// control the directive 
<input {#if !disabled} r-model={username} {/if}>
  • include
    include 用来标准引入一些内容,这些内容可能需要在初始化后指定,或可能发生变动。
    {#include template}
    template: 一个Expression,求值结果是字符串或模板AST

3.一次性绑定
由于脏检查机制的性能极大的依赖于监听器的数量,为了精确控制监听器的数量,regularjs引入了一个新的表达式语法元素@()提供了bind-once的表达式的支持. 这个被监听的表达式在检查到一次值变化就会被解除监听。 @(Expression)

<div>{ @(title) }</div> // the interpolation only trigger once

4.过滤器(支持双向过滤器)

Regular.filter( "last" , function(obj) {
  return obj[obj.length - 1];
};

Regular.filter( "lowercase" , function(text) {
  return (text).toLowerCase();
};
// Template 

<div>{list|last|lowercase}</div>
 // {list: ['Add','Update','Delete']},
// output
<div>delete</div>

三、 ES5部分新特性IE8及以下的支持

推荐学习网站:Mozilla 开发者网络

IE8以及以下:indexOf,forEach,filter; 还有一些新特性,如果需要可以参照Mozilla 开发者网络自己加进去。

module.exports = function(){
  // String proto ;
  extend(String.prototype, {
    trim: function(){
      return this.replace(/^\s+|\s+$/g, '');
    }
  });


  // Array proto;
  extend(Array.prototype, {
    indexOf: function(obj, from){
      from = from || 0;
      for (var i = from, len = this.length; i < len; i++) {
        if (this[i] === obj) return i;
      }
      return -1;
    },
    forEach: function(callback, ctx){
      var k = 0;

      // 1. Let O be the result of calling ToObject passing the |this| value as the argument.
      var O = Object(this);
          //Let len be ToUint32(lenValue).
      var len = O.length >>> 0; 

      if ( typeof callback !== "function" ) {
        throw new TypeError( callback + " is not a function" );
      }

      // 7. Repeat, while k < len
      while( k < len ) {

        var kValue;

        if ( k in O ) {

          kValue = O[ k ];

          callback.call( ctx, kValue, k, O );
        }
        k++;
      }
    },
 
    filter: function(fun, context){

      var t = Object(this);
      var len = t.length >>> 0;
      if (typeof fun !== "function")
        throw new TypeError();

      var res = [];
      for (var i = 0; i < len; i++)
      {
        if (i in t)
        {
          var val = t[i];
          if (fun.call(context, val, i, t))
            res.push(val);
        }
      }

      return res;
    }
  });

  // Function proto;
  extend(Function.prototype, {
    bind: function(context){
      var fn = this;
      var preArgs = slice.call(arguments, 1);
      return function(){
        var args = preArgs.concat(slice.call(arguments));
        return fn.apply(context, args);
      }
    }
  })
  
  // Array
  extend(Array, {
    isArray: function(arr){
      return tstr.call(arr) === "[object Array]";
    }
  })
}

四、快速起步

1.初始化结构
<div id="app"></div>

<!-- 引入regular.js -->
<script src="https://rawgit.com/regularjs/regular/master/dist/regular.js"></script>
<script id="hello" type="text/regular" name="hello">
 <p> Hello, Guest</p>
 <p>Hello,{username}</p>
</script>


<script>
//利用Regular构建你的app吧
var HelloRegular = Regular.extend({
  template: '#hello',
  data:{name: 22}
});

// initialize component then $inject to #app's  bottom
var component = new HelloRegular({
  data: {username: "leeluolee"},
  config: function(data){
        //会在模板编译 之前 被调用,config一般是用来初始化参数,
       //它接收一个Object类型的参数data, 即你在初始化时传入的data参数.
  },
  init: function(){
    //会在模板编译 之后(即活动dom已经产生)被调用. 你可以在这里处理一些与dom相关的逻辑
  }
});
component.$inject('#app'); 
</script>
  • Regular.extend
    Regular.extend用来创建一个继承自Regular的组件类,所有传入extend的属性都会成为此组件类的原型属性。
  • ** template**
    一般来讲一个组件会需要一个模板来描述组件的结构,这里我们传入包含模板的容器节点的选择器(你也可以直接传入模板字符串)
  • data
  • 组件component可能需要一些初始化状态,这些数据我们可以在实例化组件时作为data传入。
  • 需要注意的是在实例化组件传入的参数会被作为实例属性,所以可以在这里覆盖extend的定义(原型属性)。
  • $inject(node[, direction])
    这是个组件的实例方法,会将组件插入到目标节点制定位置,实际上就是js里面的append
  • bottom[默认参数]:作为node的lastChild插入
  • top:作为node的firstChild插入
  • after:作为node的nextSibling插入
  • before:作为previousSibling插入
2.插值
 Hello, {username}
3.if/else逻辑控制
{#if index==1}
  Hello, 我是管理员
{#elseif index==2}
  Hello, 我是录单员
 {#else}
  Hello, 我是报价员
{/if}
4.事件
4.1DOM事件

通过if动态绑定click事件

 <button {#if index==1} on-click={this.delete()} {/if}>删除</button>
  • 支持基本的dom事件,eg:on-focus,on-blur,on-click,on-change
  • 支持事件代理
    • 所有的on-*都会在节点上绑定对应事件,在某种情况下(比如大列表),这种方式不是很高效。
    • $event对象:那你可以使用$event来获取事件对象,这个变量会再每次事件触发时临时的定义在data.$event中,即你可以在模板里直接使用它。$event对象是被修正过的,在兼容IE6的前提下,你可以使用以下规范内的属性:
属性
  • 你可以使用delegate-来代理on-来避免可能的性能问题。regularjs只会绑定唯一的事件到组件的第一父元素(无论你是如何$inject的)来处理组件内的所有代理事件。

    <div delegate-click="{this.editUser($event)}">
        {#if list as item}
        <div>
            <span>{item.name}</span>
            <button data-id="{item.id}" title="editUser" data-id-id="{item.id}">编辑</button>
        </div>
        {/if}
    </div>
    
    editUser: function(event){
         var target = event.target;
         if (target.title == "editUser") { //指定触发节点
           var id = target.dataset.id; 
           utils.ajax( 'user/initUpdate', { id: id }, 'POST')
            .then(function(data) {
              //执行对应的操作
            });
        }
    }
    

总结:$event对象被修正过,结合委托,我们可以把需要的值通过这个对象取出来,注意这里传递的id,命名方式必须是data-*的形式。 该对象封装之后类似jquery里面的$(this)对象。

上面代码target. dataset里面包含的值
  • 可以通过Regular.event来扩展自定义ui事件,比如on-enter,on-tap等等,如下示例on-enter

    var dom = Regular.dom;
    Regular.event('enter', function(elem, fire){
      dom.on(elem, "keypress", update); //绑定监听事件
      function update(ev){
        if(ev.which == 13){ // ENTER key
          ev.preventDefault();
          fire(ev); // if key is enter , we fire the event;
        }
      }
      return function destroy(){ // return a destroy function
        dom.off(elem, "keypress", update);
      }
    });
    // use in template
    <textarea on-enter={this.submit($event)}></textarea>`
    
4.2组件事件

Regularjs集成了一个轻量级的Emitter,使得所有组件都可以使用以下接口来实现事件驱动的开发

  • component.$on: 用于添加事件监听
  • component.$off: 用于解绑事件监听
  • component.$emit:用于触发某个事件
 this.$on("save",function(arg){
     console.log(arg);
 });
this.$emit("save",2)
this.$off("save");
4.3组件事件和DOM事件的异同之处。
  • 事件的共性

回调方式取决于你传入的属性值
取决于你传入的值是表达式插值还是普通属性,regularjs会做不同的响应处理,例如:

  表达式(`e.g. on-click={this.remove()}`)
   <div on-click={this.remove(index)}>Delte</div>
    remove: function(index){
      this.data.list.splice(index ,1);
    // other logic
  }

  非表达式(`e.g. on-click="remove"`)
  <div on-click="remove">Delte</div>
  var Component = Regular.extend({
   template:'example',
   init: function(){
     this.$on("remove", function($event){
         // your logic here
     })
   }
  })
  • 事件的不同

  • 组件事件是由$emit方法抛出,而DOM由用户触发,由浏览器抛出(除了自定义事件)

  • dom事件由于DOM本身的特点是可以冒泡的,但是组件事件没有冒泡这一机制。

  • $event在组件事件中是$emit传入的第一个参数,而DOM事件中是封装过的事件对象

5.指令
<input type="text" r-model={user.name}/>

内建指令有:r-class,r-model,r-hide,r-style,ref
注意:可以通过Regular. directive来扩展指令。

自定义指令方式

 Regular.directive('r-html', function(elem, value){
      this.$watch(value, function(new Value){
          elem.innerHTML = new Value
      })
})
<div class='preview' r-html='{content}'></div>
6.过滤器
//regularjs 几个内建的过滤器
<p>{ [1,2,3] |total}</p>  //6
<p>{ [1,2,3] |last}</p>  //3
<p>{ [1,2,3] |average}</p> //2

注意:可以通过Regular. filter来扩展过滤器。

//1. 自定义一个双向过滤器,后面传入一个  "{}"
Regular.filter("join",{
  get: function(origin, split) {
    return origin.join(split || "-");
  },
  set: function(dest, split) {
    return dest.split(split || "-");
  }
});

<p>{[1,2,3]|join:"-"}</p> //1-2-3  //get

enter: function(option) { 
   this.data.list = "1-2-3";
   this.$update("this.data.list | join:'-'",this.data.list); //set
   console.log(this.data.list) //["1","2","3"];
}

//2. 单向过滤器 传入一个 "function"
 Regular.filter("first",function (arr) {
    return arr && arr[0];
});
<p>{[1,2,3] | first}</p> //1

//3. format 过滤器
 Regular.filter("format", function(value, format) {
  function fix(str) {
    str = "" + (str || "");
    return str.length <= 1 ? "0" + str : str;
  }
  var maps = {
    'yyyy': function(date) {
      return date.getFullYear() },
    'MM': function(date) {
      return fix(date.getMonth() + 1); },
    'dd': function(date) {
      return fix(date.getDate()) },
    'HH': function(date) {
      return fix(date.getHours()) },
    'mm': function(date) {
      return fix(date.getMinutes()) }
  };
  //Object.keys 不支持IE8  
  var trunk = new RegExp(Object.keys(maps).join('|'), 'g'); // yyyy|MM|dd|HH|mm/g
  //or 写死 当然也可以使用for...in去循环  这个只是不支持IE6以下的浏览器
  var trunk = new RegExp("yyyy|MM|dd|HH|mm","g");
  format = format || "yyyy-MM-dd HH:mm"; 
  value = new Date(value);
  return format.replace(trunk, function(capture) { //返回一个个匹配项目
    return maps[capture] ? maps[capture](value) : ""; //返回匹配项目对应的值
  });
});

//template
<p>{time| format: 'yyyy-MM-dd HH:mm'}</p>
7. 计算属性computed

尽管regularjs的表达式支持非常完备,但是在某些情况下,创建计算属性(computed property)可以让你避免书写冗余的表达式

computed实例
template
浏览器显示
8. ref

在模板中,你可以使用ref属性来标记一个节点组件,并且ref可以动态使用<div ref="{name}"></div>。在实例化后,你可以通过component.$refs 来获取你标记的节点。相当于vue.js里面的v-el,v-ref指令的结合。

组件的引用
  //获取component
  this.$refs.address; 

  //获取节点
  <input type="text" ref="input"/>
  this.$refs.input;
9. 组件内嵌内容的使用。
组件的引用
通过this.$body使用内嵌内容
效果

1.使用this.$body的好处

this.$body内嵌内容还是属于引用组件的父级,可以操作父级的data,控制显示隐藏。对于公用组件模态框的使用很便利。

2.到底什么是$body?

$body

this.$body实际上是一个函数。运行它相当于对『内嵌的模板』进行了一次编译动作并返回一个『块』,「块」类似于一个阉割版的组件,也可以被插入到某个位置。

3.手动传入$body
在上面的例子中,我们可以很方便的通过在组件节点之间插入内容来自定义组件需要的某些模板片断,但这会引入一个问题,『当我们使用JS初始化组件时,如何定义内嵌内容?』。你可以采用手动传入的方式

var Model = new Model({
    $body: "<button on-click={showAlert=false}>close</button>"
});

4.额外话

// app.js 父调用组件alert  on-close:给组件绑定close事件
  <alert on-close="{showAlert = false}" show="{showAlert}"></alert>

// alert.template 组件模板
  <div class="alert {show?'in':''}">
      我是实例
    <button on-click="close"></button> //js里面调用this.$emit("close");
  </div>
 
通过这种给组件绑定事件的方式也是可以实现的。
屏幕快照 2017-04-20 下午4.20.57.png
10. Regular.dom

由于内部实现需要,Regular实现了部分常用的跨浏览器的dom方法,如果只是简单的dom处理,你可以直接使用Regular.dom。

内建的dom方法

1. 绑定事件
Regular.dom.on(element, event, handle);
Regular.dom.on(element,"click", function(event){
});

2. 移除事件监听器
Regular.dom.off(node, event, handle);

3. 添加节点className
Regular.dom.addClass(element, className)

4. 移除节点的某段className
Regular.dom.delClass(element, className)

5. 判断节点是否拥有某个className
Regular.dom.hasClass(element, className)

6. 根据浏览器和节点, 设置节点的textContent或innerText
Regular.dom.text(element[, value])

7. 设置或获取节点的innerHTML值
Regular.dom.html(element[, value])

8.设置或获取节点的指定属性
Regular.dom.attr(element, name [ , value])
Regular.dom.find()
Regular.dom.inject()
Regular.dom.id...
replace...
css
五、组件生命周期

了解组件的生命周期来帮我们理解Regular内部运行机制

当实例化组件的时候,会发生以下事情:

1.options将合并原型中的events,data。

options = options || {};
options.data = options.data || {};
options.events = options.events || {};
if(this.data) _.extend(options.data, this.data);
if(this.events) _.extend(options.events, this.events);

2.将options合并到this中

_.extend(this, options, true);

⚠ 实例化中传入的属性会覆盖原型属性

3.解析模板
如果已经被解析了,这步就跳过

源码

4. 根据传入的options.events 注册事件

事件绑定

5.触发$config事件,调用config函数

config事件

6.编译模板,触发一次组件脏检查

这里的脏检查是为了确保组件视图正确,到这里我们已经拥有初始化的dom元素,你可以通过$refs来获取你标记的。

compile

7.触发$init事件,并调用this.init函数。

调用init

注意:请铭记,与config的区别是,此阶段在compile之后,意味着你可以通过$refs获取到你标记的dom结构。

当component.destory()
当销毁组件时,剧情就要简单的多了。

  • 触发$destroy事件
  • 销毁所有模板的dom节点,并且解除所有数据绑定、指令等

六、构建单页面应用路由stateman

stateman相关的API:

http://leeluolee.github.io/stateman/?API-zh=undefined&doc=API&lang=zh#stateman-文档-api-new-stateman

七、require+stateman+require构建单页面应用实例

官网作者写了一个单页面应用的demo,我司目前的项目也是参照这个demo来构建的。
https://github.com/regularjs/regular-state

八、本人在项目中遇到的一些"坑"

1.关于r-model

场景1:有时候我们需要select默认选择一些值。但是option列表又是动态生成的。 就会导致r-model执行的时候,发现对应需要处于选择状态的选项是不存的,所以导致匹配不成功。 即使我们在option渲染数组请求成功之后,主动进入脏检查也是没有用的,因为r-model里面传入的值没有发现变化,所以,不会进入对应的watch事件里面进行重新赋值。

场景1解决方式:

强制selected

场景2
这个问题比较奇怪,到现在我也不知道是什么原因,所以尽量避免吧。这个问题的出现是基于场景1

template
config: function(data) {
    data.company = [{ agencyCompanyId: '', agencyCompanyName: '请选择' }];
    data.obj = {
      agencyCompanyId: '', //公司 默认选择   “请选择”
    };       
 }

init:function(){
  utils.ajax(config.path + 'list')
      .then(function(data) {
        data.entity = [{agencyCompanyId:0,name:"平台"},
        {agencyCompanyId:1,name:"宝城"}];
        _this.data.company = _this.data.company.concat(data.entity);
        _this.$update();
   });
}

结果:

结果
结果

场景2解决办法:

 data.company = [{ agencyCompanyId: '-1', agencyCompanyName: '请选择' }]; 
//不要设置为空字符串,设置 -1 这种后台不会使用的id。
2. 关于子路由模块插件问题

官方关于子路由渲染API:http://regularjs.github.io/regular-state/docs/core/view.html。 这个文档说是针对0.6版本。但是官网并没有具体的0.6以上的版本代码。所以,我们很容易搞错。

r-view

实际上官网的版本0.5.2里面并没有r-view这个指令。而是通过ref=view来标记子路由模块渲染的地方。

以下代码里面就是通过r-view来代表子路由模块插入的位置。
http://regularjs.github.io/regular-state/assets/restate.pack.js

3. 关于init函数
js
template

在init里面不能直接操作dom,因为init时此组件并没有插入到文档中,所以直接操作dom是不会起作用的。如果一定需要操作dom可以通过以下方式

  • Regular的 $ref获取你标记的dom结果。
  • 通过Regular.dom.element(component,needAll)获取。
    • 如果needAll为true,返回子节点数组。
    • 如果needAll为false,返回子节点中的第一个节点。

未完待续

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,590评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,808评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,151评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,779评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,773评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,656评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,022评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,678评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,038评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,756评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,411评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,005评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,973评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,053评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,495评论 2 343

推荐阅读更多精彩内容

  • 这篇笔记主要包含 Vue 2 不同于 Vue 1 或者特有的内容,还有我对于 Vue 1.0 印象不深的内容。关于...
    云之外阅读 5,044评论 0 29
  • Vue 实例 属性和方法 每个 Vue 实例都会代理其 data 对象里所有的属性:var data = { a:...
    云之外阅读 2,198评论 0 6
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,596评论 18 139
  • 在微博上发,有个可爱的朋友让我出分步骤的图,受宠若惊,谢谢你能喜欢我 分步骤图:
    墨墨发光阅读 281评论 2 4
  • 1)开始菜单中输入"打开或关闭",找到"打开或关闭Window功能",如下图所示![](http://upload...
    ZhouMac阅读 1,204评论 1 4