前言
由于历史原因,转转/找靓机双卖场各场景(频道页、主搜、首页推荐)、各筛选组件(品牌墙、快筛、抽屉筛、机型筛选)前端展现样式、交互、筛选内容不一致。双卖场筛选数据源不一致,“筛选配置诉求产生”——“配置筛选线上生效”需要多方介入,流程复杂,运营无法闭环。因此,需要统一双卖场各场景筛选的前端表达、后台配置等,对配置流程、运营进行提效。
需求背景
1. 后台可配置
转转内部有一个筛选项配置管理后台(如下图所示),可随意新增/配置筛选项,以及筛选项展示的图文配置管理,筛选分类的配置管理,并且实时表现到到前端筛选组件侧。
2. 统一前端表达定义
常见的卖场页面往往包括:搜索区域、Banner 区域、筛选区域、商品 Feed 流区域等。(如下图所示)。
其中通用筛选区域的定义主要分为三大区域:细选区、筛选区、快筛区。这三个区域就是“通用筛选组件”需要实现的基础能力。
3. 一次开发多方复用
为了便于转转/找靓机双卖场的快速接入,需要提供一套通用的技术能力与组件方案,并纳入公司内部的公共组件库,便于各场景各页面的便捷接入,减少重复开发。
技术背景
转转搜索推荐侧提供的 schema 数据,是由平台侧与搜索侧积累了多年的配置筛选视图与关系控制的一套数据结构体系。内部称之为 “Style 体系” 技术方案(具体数据结构如下图所示)。
开发 FE 筛选组件时,基于 现有的 “Style 体系” Schema 进行设计的,相对于站在巨人的肩膀上进行组件的设计与开发,并且该体系方案已经在客户端原生页面线上稳定运行。
style体系元数据
整体设计
设计前期的一些思考:
- 在 style 体系下,实现一个类似于低代码的 json schema 引擎,只需要关注原子视图,方便开发和维护。参考业界主流的 json schema 引擎,无法满足产品诉求和强交互的效果
- 在 style 体系下,原子 ui 组件使用时,约束性很强,比如 201 只能在 200 下面出现。因此,没有必要通过引擎方式方式来进行组件树渲染实现,反而会增加工作量,因为 json schema 引擎更适合 非耦合性的组件树渲染,也就是组件树中的 nodes 自由组装,而不进行各个 node 之间的约束。
- json schema 引擎的集中渲染模式,同步迁移到小程序侧时成本大。
结合业务场景以及不同方案的技术特点,经过综合考虑,最终采取了传统的SFC组件方式进行设计实现。
设计原则:
- 分层思想
由于需要封装统一搜索推荐服务能力,并且满足各个筛选区域面板数据流、交互流的处理,保证逻辑清晰,因此对组件进行分层设计,主要分为数据层、服务层、视图层。
- 职责分离
组件内部数据模型进行模块化分类,各施其职,方便维护扩展更多能力
- 迪米特法则
避免使用全局属性,防止高耦合度,造成无意识的不必要的污染,满足组件复用性。
整体架构:
架构图
主要能力实现
模型分层分类策略
为了方便对数据模型层进行拉取、管理、聚合处理,针对各个业务能力的数据模型进行了原子拆分。主要分为 BizDataModel 基础业务数据模型、 SchemaModel style体系元数据模型、 SearchListModel 搜索交互缓存模型、 LocalModel 定位数据模型 MobileModel 机型模型 LegoModel 埋点数据模型。
// Service 层,负责服务端数据获取,model层数据整合
export class Service {
ctx: any = null
constructor(ctx: any) {
this.ctx = ctx
}
request() {}
queryFilterConfig() {}
queryModelFilterConfig() {}
getMobileModelData() {}
// ...
}
// Model 层, 负责 model 的初始化、转换、增删改查等
export class BizModel {
$bizData: FilterParamType = defaultBizData
set() {}
get() {}
reset() {}
}
class SchemaDataModel {}
// ...
// SFC 层,视图层的各种交互
export default {
// ...
data() {
const modelCtx = {
BizDataModel: new BizDataModel(),
SearchListModel: new SearchListModel(),
SchemaModel: new SchemaModel(),
LocalModel: new LocalModel(),
MobileModel: new MobileModel(),
LegoModel: new LegoModel(),
}
const serviceCtx = new Service(modelCtx)
return {
modelCtx,
serviceCtx,
}
}
// ...
}
数据模型分层
筛选项平铺更新算法
平铺更新算法
- 把 schemaDataModel 作为全局共享数据,方便其他组件获取并调用 update 方法实现数据高效更新
- 平铺整个筛选组件大 JSON 数据,辅助后续更新操作,降低整体时间复杂度。
- 在 update 内部里新建 Map 数据结构,把平铺好的数据用 Map 对其进行包裹,提升更新效率
- 每个 style 粒子都有一个 value 来标识,但是在快筛和全部筛选中可能会存在 value 相同,但 style 不同的情况,它们本质上其实是同一种。这就会出现在 100 和 200 下都有次日达筛选项,在 100 下点击次日达,server 会把 100 和 200 下的次日达 state 都置为 1,再次点击 100 下的次日达将取消筛选,但是 200 下的次日达 state 仍然为 1,导致接口入参又带上了次日达,无法取消选中的情况。我们采用 Map 可以有效避免这一点,把平铺好的数据用 Map 处理,就算是 value 相同也会只取最后一个,这时不用关心 Map 里存储的是不是正确的,我们会拿 update 里透传的真实点击项参数去做替换,这样就可以保证传给后端的是最新。
筛选组件与搜索框组件联动
筛选组件在交互的处理上,需要与顶部搜索框组件进行联动处理。
具体交互处理流:
交互效果:
自动吸顶与滚动
组件提供了 autoScroll、scrollOffsetTop 两个参数,页面滚动采用浏览器原生方法 window.scroll。autoScroll 控制是否开启点击吸顶、scrollOffsetTop 控制滚动的距离。
需要注意的是:获取下拉面板要展示的top位置时候,要在页面滚动画结束后执行,DOM scroll api设计缺陷拿不到动画执行时长。因此需要毛估一个 50 ~ 100ms, setTimeout 延时进行处理。
- 获取当前页面的可滚动距离 contentHeight
const bodyScrollHeight = document?.body?.scrollHeight;
const documentScrollHeight = document?.documentElement?.scrollHeight;
// 确保获取的是页面最大高度 const scrollHeight
const scrollHeight = Math.max(bodyScrollHeight, documentScrollHeight);
const clientHeight = document?.documentElement?.clientHeight;
// 获取的是页面的可滚动距离 const contentHeight
const contentHeight = scrollHeight - clientHeight;
return contentHeight >= 0 ? contentHeight : 0;
- 判断有没有传 scrollOffsetTop
const noHasScrollOffsetTop = !this.scrollOffsetTop && +this.scrollOffsetTop !== 0;
- 判断要不要做吸顶
// 1. 开启滚动_传了scrollOffsetTop
const hasScrollOffsetTopCase =
!noHasScrollOffsetTop && +canScrollDistance < +this.scrollOffsetTop;
// 2. 开启滚动_没传scrollOffsetTop
const noScrollOffsetTopCase = noHasScrollOffsetTop && +canScrollDistance < +this.rect?.top;
// 3. 禁用滚动或到了顶部
const noScroll = (this.rect?.top === 0) || !this.autoScroll;
- 判断吸顶距离
window.scrollTo(0, !noHasScrollOffsetTop ? this.scrollOffsetTop : this.rect?.top || 0);
自动吸顶下拉效果
滚动处理
// 纵向滚动
function scrollToTopDom(scroller, to, duration, callback) {
// 记录当前滚动位置
const start = scroller.scrollTop;
// 还需滚动多少距离
const change = to - start;
// 每一帧的毫秒增量(一般是 1000/60 = 16.67)
const increment = 20;
// 关键帧动画的轮回次数
let currentTime = 0;
// 动画执行时间,默认值500毫秒
duration = typeof duration === 'undefined' ? 500 : duration;
// 关键帧动画函数
const animateScroll = function () {
// 累计毫秒树
currentTime += increment;
// 用 easeInOut 的过渡函数找出每一帧的应滚动到的距离
const val = Math.easeInOutQuad(currentTime, start, change, duration);
// 赋值
scroller.scrollTop = val;
// 判断,动画出口
if (currentTime < duration) {
// 时间没到,继续执行
requestAnimFrame(animateScroll);
} else {
// 时间到了,如果有回调则调用
isFunction(callback) && callback();
}
};
// 第一次调用
animateScroll();
}
// 横向滚动
function scrollLeftTo(scroller, to, duration) {
// 执行次数
let count = 0;
// 当前滚动位置
const from = scroller.scrollLeft;
// 需要执行的次数,默认一次
const frames = duration === 0 ? 1 : Math.round(duration / 16);
// 动画函数
function animate() {
// 赋值
scroller.scrollLeft += (to - from) / frames;
// 判断,动画出口
if (++count < frames) {
// 时间没到,继续执行
window.requestAnimationFrame(animate);
}
}
// 第一次调用
animate();
}
默认滚动效果
总结
目前,公共筛选组件已经接入覆盖了 80% 的卖场筛选场景页面,线上运行效果稳定,对原有筛选能力的交互体验进行了全方位的提升与效果上的统一。
当然,在接入复杂业务场景时,也发现了一些问题,比如,在单个页面中存在多个通用筛选组件复用的业务场景时,由于设计时数据层使用的全局的实例化model,没有实例化到绑定到每个组件 context 下,进而导致组件复用时出现组件之间数据层造成相互污染的现象;通过类名来获取 dom 计算滚动动画的 offset 结果,导致计算异常进而交互行为异常等等。这些也是在组件设计与代码编写时需要刻意注意的地方,特别是平常较少开发组件的同学。
作者:@荣豪/荆凯/声亮
来源:微信公众号:大转转FE
出处:https://mp.weixin.qq.com/s/Cl38tP5iUs3UBeCpROvw8A