微信原生小程序 Timeline 组件实现

发布于:2025-08-10 ⋅ 阅读:(13) ⋅ 点赞:(0)

目录

组件分析设计

Timeline 属性

Timeline-item 属性

Timeline-item 插槽 (Slot)

组件实现

timeline

timeline-item

排序:父子组件间关系

注意

完整代码

timeline-item

timeline

styleToCssString.js


以时间轴组件为例简单聊一下小程序的组件开发。

时间线组件

https://developers.weixin.qq.com/s/2MIXvgmr8N2xhttps://developers.weixin.qq.com/s/2MIXvgmr8N2x

组件分析设计

在着手写代码之前,还是先做一翻分析设计。

分析什么?我们需要分析业务方对组件的详细需求、交互以及设计稿效果,弄清楚有没有什么特别的地方,需求是否适合使用时间轴组件来呈现等,对于不合理的需求,作为开发我们有责任提出问题和建议来。

设计什么?我们需要设计组件的目录结构、API 以及对使用者友好的示例文档,通常除了 API 外,目录结构、命名和文档我们都会现有一个规范,按给定的规范做即可,但是如何设计一组好的 API 就需要开发者具有一定的经验。

当然,对于常用的组件,我们也许用不着这么认真,直接参考市面上已有的组件库找着样子抄就行了,事实上我们除了少数几个组件别具特色外别家没有外,其他组件我们也是直接参考别的组件库是怎么做的,但我这里抛砖引玉吧,不仅仅是开发组件,任何需求的迭代开发都是一样的,特别是复杂的需求,我们更需要设计。

对于组件开发来说,最好的设计文档就是示例文档,文档先行。看一下 Timeline 组件的文档吧:

Timeline 属性

参数 说明 类型 默认值
reverse 是否倒序排列 boolean true

Timeline-item 属性

参数 说明 类型 默认值
timestamp 时间戳 string -
customDot 是否自定义时间轴点 boolean false
dotStyle 时间轴点样式 string, object -
color 时间轴点和时间戳应用的颜色 string #888

Timeline-item 插槽 (Slot)

插槽名 说明
(默认) 内容区域
dot 自定义时间轴点(仅当 customDot=true 时生效)
operation 显示在时间右侧的内容

外部样式类 (externalClasses)

类名 说明
custom-class Timeline 信号点样式表
timestamp-class Timeline-item 时间节点样式表
content-class Timeline-item 内容节点样式表

什么不支持自定义 slot,什么地方支持自定义,这就很清晰了,事件轴点可以自定义样式,时间轴的内容体可以自定义,以及一些外部样式 class。

当然这是最终完善的文档,我们首先应该定义好组件的 Attributes(Props)、Slot ,命名、类型这些都事先定义好,这是最基础的,分析出来我们的组件应该提供什么样的能力,定义什么样的接口。其次,我们应该写好代码示例,先想好我们的组件是如何使用的,然后我们再支持这样的实现。这里可以看出,组件使用了父子嵌套组件模式:

<pps-timeline reverse="{{false}}">
  <pps-timeline-item
    wx:for="{{activity}}"
    wx:key="{{index}}"
    timestamp="{{item.timestamp}}"
    dotStyle="border-color: #33cd5f; color: #33cd5f"
  >
    {{item.content}}
  </pps-timeline-item>
</pps-timeline>

为什么这里要使用父子组件模式呢,其实不这样也是可以很好的完整组件。做好了这些工作后我们开始写代码吧。

组件实现

timeline

因为我们是使用父子组件嵌套模式,所以在创建目录时就可以这样体现

timeline
--index.wxml
----timeline-item
------index.wxml

当然,在小程序中要实现父子组件关联关系并不是非要这样,事实上没有层级规定限制,父子组件平级放置也是可以的,但我推荐这样,阅读起来更清晰名了。

父组件内部其实很简单,就是一个 slot,以便能够放入子组件

<view class="pps-timeline custom-class {{reverse?'desc':'asc'}}">
  <slot />
</view>

custom-class 是定义组件的根外部样式,以便在外部传入样式,为什么要这样呢,小程序的组件技术采用的 WebCompontent 技术,不了解的同学可以搜索一下,这里我写了demo

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
</head>
<body>
    <template id="geekbang-t">
        <style>
            p {
                background-color: brown;
                color: cornsilk;
            }
            div {
                width: 200px;
                background-color: bisque;
                border: 3px solid red;
                border-radius: 10px;
            }
        </style>
        <div>
            <p>time.geekbang.org</p>
            <p>time1.geekbang.org</p>
        </div>
        <script>
            function foo() {
                console.log('foo')
            }
        </script>
    </template>

    <script>
        class Geekbang extends HTMLElement {
            constructor() {
                super();
                // 获取组件模板
                const content = document.getElementById('geekbang-t').content
                // 创建影子DOM节点
                const shadowDOM = this.attachShadow({ mode: 'open' })
                // 将模板添加到影子DOM上
                shadowDOM.appendChild(content.cloneNode(true))
            }
        }
        customElements.define('geek-bang', Geekbang)
    </script>

    <geek-bang></geek-bang>
    <geek-bang></geek-bang>
    <div>
        <p>time.geekbang.org</p>
        <p>time1.geekbang.org</p>
    </div>
</body>

reverse 是个 prop ,控制时间轴是顺序展示还是倒序展示,这个在父组件来控制是最好不过的(当然,你也可以把传入的数组排序好也是一样的)。

timeline-item

核心都在这个里面,我们先来分析一下 DOM 结构,以便确定怎么构建 HTML 结构

排序:父子组件间关系

组件间关系这是这个组件最关键的地方,不同于 Vue.js 组件方案,只有实现这个才能实现在 timeline 上实现 reverse。关于组件的关联关系详细可以看文档

在 timeline/index.js 和 timeline-item/index.js 中分别定义 timeline 是 timeline-item 的父级,timeline-item 是 timeline 的子级,由 relations 选项来定义。关键是确定最后一个节点,因为最后一个节点是没有连接线的,这个需要处理

(其实数据不复杂的情况下更建议对传入的数组进行排序,这样就不需要处理 DOM 结构了)。

timeline/index.js

relations: {
    './timeline-item/index': {
      type: 'child',
      linked: function (target) {
        // 每次有custom-li被插入时执行,target是该节点实例对象,触发在该节点attached生命周期之后
        this._getAllChildren()
      },
      unlinked: function (target) {
        this._getAllChildren()
      }
    }
  },

timeline-item/index.js

relations: {
    '../../timeline/index': {
      type: 'parent'
    }
  },

有了关联关系,我们可以调用 this.getRelationNodes 方法获取所有的子节点,这个写在 timeline-item/index.js 中

methods: {
    _getAllChildren() {
      const nodes = this.getRelationNodes('./timeline-item/index')
      if (nodes.length) {
        const lastIndex = nodes.length - 1
        const { reverse } = this.data
        nodes.forEach((element, index) => {
          const isLast = index === lastIndex
          element.updateIsLastElement({
            index,
            isLast,
            reverse
          })
        })
      }
    }
}

然后遍历每一个子节点,调用子节点 methods 中的 updateIsLastElement 处理方法。这里需要特别注意的是 在 unlinked 中也要再次调用 _getAllChildren ,因为当使用 setData 删除一个子项时需要重新计算子节点个数。

所谓的排序,就是 Flex CSS 操作而已

.pps-timeline.desc {
  display: flex;
  flex-direction: column;
}
.pps-timeline.asc {
  display: flex;
  flex-direction: column-reverse;
}

这个组件还有很多可以改进的地方,这里作为一种思路吧。

注意

在引用小程序端的时候,父子组件都需要在 json 中引入,如果你的项目是用 npm 进行管理的,那么不要在全局的 app.json 中引入,要在使用的页面中引入,否则引用不到。

完整代码

timeline-item

js

import styleToCssString from '../utils/styleToCssString.js'
Component({
  options: {
    addGlobalClass: true,
    multipleSlots: true,
  },
  externalClasses: ["timestamp-class", "content-class"],
  relations: {
    "../../timeline/index": {
      type: "parent",
    },
  },
  properties: {
    timestamp: String,
    customDot: Boolean,
    dotStyle: {
      optionalTypes: [String, Object],
      value: "",
      observer(newVal) {
        console.log("styleToCssString", styleToCssString(newVal));
        this.setData({
          extStyle: styleToCssString(newVal),
        });
      },
    },
    color: {
      type: String,
      value: "currentColor",
      observer(newVal) {
        if (newVal) {
          let extStyle = this.data.extStyle;
          if (extStyle) {
            extStyle = extStyle + ";";
          }
          this.setData({
            extStyle: extStyle + `color:${newVal}`,
          });
        }
      },
    },
  },
  data: {
    index: 0,
    isLast: false,
    reverse: true,
    extStyle: "",
  },
  methods: {
    updateIsLastElement({ index, isLast, reverse }) {
      this.setData({ index, isLast, reverse });
    },
  },
});

wxml

<view class="pps-timeline-item">
  <view class="item-wrapper"> 
    <view class="item-timestamp">
      <view class="timestamp-class">
        <!-- 自定义点 -->
        <slot wx:if="{{customDot}}" name="dot"></slot>
        <!-- 默认点 -->
        <text wx:else class="dot" style="{{ extStyle }}"></text>
        <text class="timestamp" style="color:{{color}}">{{timestamp}}</text>
      </view>
      <!--  操作 -->
      <slot name="operation" />
    </view>
    <!-- 内容  如果是倒序 则隐藏最后一个 -->
    <view wx:if="{{reverse}}" class="item-desc content-class {{isLast?'hiddenBorder':''}}">
      <slot />
    </view>
    <!-- 内容  如果是正序 则隐藏第一个 -->
    <view wx:else class="item-desc content-class {{index == 0?'hiddenBorder':''}}">
      <slot />
    </view>
  </view>
</view>

css

.pps-timeline-item {
  font-size: 28rpx;
}

.item-wrapper .item-timestamp {
  display: flex;
  justify-content: space-between;
  font-family: PingFangSC-Regular;
  color: #888;
}
.item-wrapper .dot {
  display: inline-block;
  width: 14rpx;
  height: 14rpx;
  background: #fff;
  border: 2px solid #bcc1cc;
  border-radius: 50%;
}
.item-wrapper .timestamp {
  margin-left: 20rpx;
}
.item-wrapper .item-desc {
  /* border-left: 2rpx dashed #BCC0CC; */
  position: relative;
  line-height: 44rpx;
  padding-bottom: 44rpx;
  margin-left: 5px;
  margin-top: 6px;
  margin-bottom: 6px;
  padding-left: 30rpx;
  word-wrap:break-word;
}
.item-wrapper .item-desc::after{
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 2rpx;
  border-left: 2rpx dashed #BCC0CC;
  /* transform: translateX(-50%); */
}
.item-wrapper .item-desc.hiddenBorder::after {
  border-left: 0rpx;
}

json

{
  "component": true,
  "usingComponents": {}
}

timeline

js

Component({
  options: {
    // 开启全局样式支持
    addGlobalClass: true,
    // 开启多Slot支持
    multipleSlots: false,
  },
  //  外部样式类 
  externalClasses: ["custom-class"],
  //  relations 关系组件
  relations: {
    "./timeline-item/index": {
      type: "child",
      linked: function (target) {
        // 每次有custom-li被插入时执行,target是该节点实例对象,触发在该节点attached生命周期之后
        this._getAllChildren();
      },
      unlinked: function (target) {
        // 每次有custom-li被移除时执行,target是该节点实例对象,触发在该节点detached生命周期之后
        this._getAllChildren();
      },
    },
  },
  properties: {
    // 是否倒叙
    reverse: {
      type: Boolean,
      value: true,
    },
  },
  methods: {
    _getAllChildren() {
      // 获取所有子节点
      const nodes = this.getRelationNodes("./timeline-item/index");
      if (nodes.length) {
        // 最后一个节点索引
        const lastIndex = nodes.length - 1;
        // 倒叙
        const { reverse } = this.data;
        nodes.forEach((element, index) => {
          // 最后一个节点
          const isLast = index === lastIndex;
          element.updateIsLastElement({
            index,
            isLast,
            reverse,
          });
        });
      }
    },
  },
});

wxml

<view class="pps-timeline custom-class {{reverse?'desc':'asc'}}">
  <slot />
</view>

wxss

.pps-timeline.desc{
  display: flex;
  flex-direction: column;
}
.pps-timeline.asc{
  display: flex;
  flex-direction: column-reverse;
}

json

{
  "component": true,
  "usingComponents": {
    "pps-timeline-item": "./timeline-item/index"
  }
}

styleToCssString.js

'use strict';

/**
 * CSS properties which accept numbers but are not in units of "px".
 */
var isUnitlessNumber = {
  boxFlex: true,
  boxFlexGroup: true,
  columnCount: true,
  flex: true,
  flexGrow: true,
  flexPositive: true,
  flexShrink: true,
  flexNegative: true,
  fontWeight: true,
  lineClamp: true,
  lineHeight: true,
  opacity: true,
  order: true,
  orphans: true,
  widows: true,
  zIndex: true,
  zoom: true,

  // SVG-related properties
  fillOpacity: true,
  strokeDashoffset: true,
  strokeOpacity: true,
  strokeWidth: true
};

/**
 * @param {string} prefix vendor-specific prefix, eg: Webkit
 * @param {string} key style name, eg: transitionDuration
 * @return {string} style name prefixed with `prefix`, properly camelCased, eg:
 * WebkitTransitionDuration
 */
function prefixKey(prefix, key) {
  return prefix + key.charAt(0).toUpperCase() + key.substring(1);
}

/**
 * Support style names that may come passed in prefixed by adding permutations
 * of vendor prefixes.
 */
var prefixes = ['Webkit', 'ms', 'Moz', 'O'];

// Using Object.keys here, or else the vanilla for-in loop makes IE8 go into an
// infinite loop, because it iterates over the newly added props too.
Object.keys(isUnitlessNumber).forEach(function (prop) {
  prefixes.forEach(function (prefix) {
    isUnitlessNumber[prefixKey(prefix, prop)] = isUnitlessNumber[prop];
  });
});

var msPattern = /^ms-/;

var _uppercasePattern = /([A-Z])/g;

/**
 * Hyphenates a camelcased string, for example:
 *
 *   > hyphenate('backgroundColor')
 *   < "background-color"
 *
 * For CSS style names, use `hyphenateStyleName` instead which works properly
 * with all vendor prefixes, including `ms`.
 *
 * @param {string} string
 * @return {string}
 */
function hyphenate(string) {
  return string.replace(_uppercasePattern, '-$1').toLowerCase();
}

/**
 * Hyphenates a camelcased CSS property name, for example:
 *
 *   > hyphenateStyleName('backgroundColor')
 *   < "background-color"
 *   > hyphenateStyleName('MozTransition')
 *   < "-moz-transition"
 *   > hyphenateStyleName('msTransition')
 *   < "-ms-transition"
 *
 * As Modernizr suggests (http://modernizr.com/docs/#prefixed), an `ms` prefix
 * is converted to `-ms-`.
 *
 * @param {string} string
 * @return {string}
 */
function hyphenateStyleName(string) {
  return hyphenate(string).replace(msPattern, '-ms-');
}

var isArray = Array.isArray;
var keys = Object.keys;

var counter = 1;
// Follows syntax at https://developer.mozilla.org/en-US/docs/Web/CSS/content,
// including multiple space separated values.
var unquotedContentValueRegex = /^(normal|none|(\b(url\([^)]*\)|chapter_counter|attr\([^)]*\)|(no-)?(open|close)-quote|inherit)((\b\s*)|$|\s+))+)$/;

function buildRule(key, value) {
  if (!isUnitlessNumber[key] && typeof value === 'number') {
    value = '' + value + 'px';
  } else if (key === 'content' && !unquotedContentValueRegex.test(value)) {
    value = "'" + value.replace(/'/g, "\\'") + "'";
  }

  return hyphenateStyleName(key) + ': ' + value + ';  ';
}

function styleToCssString(rules) {
  var result = ''
  if (typeof rules === 'string') {
    return rules
  }
  if (!rules || keys(rules).length === 0) {
    return result;
  }
  var styleKeys = keys(rules);
  for (var j = 0, l = styleKeys.length; j < l; j++) {
    var styleKey = styleKeys[j];
    var value = rules[styleKey];

    if (isArray(value)) {
      for (var i = 0, len = value.length; i < len; i++) {
        result += buildRule(styleKey, value[i]);
      }
    } else {
      result += buildRule(styleKey, value);
    }
  }
  return result;
}

export default styleToCssString


网站公告

今日签到

点亮在社区的每一天
去签到