这一年我的对组件的思考

这一年,对于我来说真是压力空前,之前公司接需求的时候大部分情况是看排期,如果排期紧张再协调安排处理。

BUT! 来这边以后概况大致如下:

这边有个需求来处理下,什么?有其他任务?那不是很正常;

优先级?并行!都很重要,并行很正常,我这里也有好几个事并行呢,DeadLine是xxx号;

做的差不多了您看看?整理好一份文档描述清楚,以便沉淀;

你上次做的那个需求有地址吗,有相关介绍吗,有实现步骤,组件放在哪里了?这样聊不清楚,你写一份文档;

Hi all,这里有个地方我觉得这样处理非常零散,这不好…那不好…,我觉得可以这样!——好这件事,你负责跟进就按照你的想法处理。

这里我重新做了一个库,可以把这些收集统一处理。

哇,不错啊,那可以把我们框架的加载都使用这个处理,你抽空处理下?

可是,这样影响面会不会….

重构完了,已经发到预发半个月了,有一些业务需求已经对这部分改动有依赖了,需要准备一波上线了?
这样直接发不行,风险太高了,你去梳理一份上线计划书,对现在线上的几个域的支持页面都做一份回归测试;

这里有一项新的技术?你抽时间研究一下,写一份文档,准备一次分享会;

最近招聘怎么没动静了?这样,大家以后晚上21-21.30大家一起集体捞简历。

KPI沟通。你近半年做的事情挺多,做的效果也不错,同事对你的评价也挺高。但是都是比较零散的点,没有一个面输出,期待你后面有更杰出的表现;

怎么才能有一个面的输出,给点指导意见?这样吧,你先把你近半年所有做过的事情详细的写一份文档给我看看,我给你点意见;

华丽丽的分割线


好了,本来是想随便唠两句引言一下,没想到一下子写了这么多。呼~ 突然有种排出一身毒素的感觉,爽!哈哈哈

我想了很久,这一年的工作我有什么地方做的不够好,或者说什么地方我做的没有像别人做的那么好。

嗯,从这点来讲,至少我现在还是在思考如何进步,不像之前觉得自己已经做的非常不错,无人能及了。

趁着休息日,还有点空闲时间,咱们来聊点技术吧!

19年6月左右,我发布过一篇文章《Bit初体验》 。在梳理这篇文章的过程中,我可以说深度体验了一把 bit 所提出的概念和做法,就像一颗种子种在我的脑海中,一开始我觉得这东西没什么。

我还记得我第一次与我的同事分享 bit 后,他说:

emm,虽然你讲了这么多,但是我觉得好像没有那么…有体感?

感觉没什么卵用?

啊,emm,既然你说了,就像你说的。我觉得我们现在如果引入bit 会不会对我们的日常工作带来很多额外的工作量。

这种反应很正常,我是在18年初,就在Vue的官网见到过 bit ,当时我点进去大致浏览过一下。我当时的感受就是,没什么卵用,无非就是 ” 前端垂直领域的 git “。对国内的支持情况还不咋地,连一篇像样的中文文档都找不到。

在我们的团队中一下子直接切换到 bit 的工作流,这确实不现实,在公司有那么多的基础建设都不知道bit这么个玩意。

但是,bit 的做法和概念,却是非常非常有价值和可以借鉴的!

所以,我想做一件事情,一步一步的把 bit 的玩法用我们熟悉的方式引入进来甚至有所延伸扩展,让大家认同其中的好处和价值。

随着近些年”微前端“概念的不断酝酿,越来越多的团队开始着手将自己的业务处理为不同组件,然后通过一定的为前端做法,编排到一个业务页面中去。

那么对于组件的维护就会变得越来越重要。所以,先来看看现在大多数团队是怎么维护组件的吧!

  • 大库型,Antd、Element标准的大库型
  • 一次型,完全业务组件,用完一次再也不维护
  • 高复用型,一看就应该单独封装以后给其他人用,比如:视频播放器
  • 二次封装型,一些库拿原生js写的,拿来包装一下当做组件直接引入
  • 项目融合型,与业务项目在一起,混合store,不分你我

我暂时能想到的就这几种类型的组件,如果你的团队也在维护自己的一套组件库,那么应该很容易理解我上面所说的。

我相信,既然这么做了,肯定有这么做的理由和好处,没有人会闲着没事找麻烦做不是,那么这些做法都有什么好处和痛点呢?我从几个方面入手分别分析一下。

组件嘛,当然是最快能跑起来,最方便能看到效果最好咯。就这点来讲,还有什么比直接在业务项目里撸组件更快的方式吗!?

现在用个展示的面板,立马去components目录撸一个。

数据?不是有store吗?引入进来不就拿到数据了!

所见即所得,现在改完马上看到页面上的效果!无法反驳..

这么看确实开发这个组件是好快了,但是从整个业务需求实现来看,这么做真的是最快的吗?如果这样的做法是最快捷的,那为什么那么多团队在强调沉淀、封装、抽象呢?

其实很多组件当时看起来,这辈子就只可能用一次,不用封装。可是往往交互稿过来的时候就会发现,这个样式好像我在哪里见过。然后去各种业务项目里一顿翻,哇终于找到了,复制过来发现各种爆红,定睛一看,store???

所以,聪明的团队早已洞察这一切,让我们把组件都维护到同一个地方,然后大家写好文档,用的时候从库里面取就可以了,有Bug的话统一修复就是,棒👍!

于是乎,大家便如火如荼的开始的组件抽象,组件整改的浩大工程。

一开始,一般会有一个团队中较为靠谱、能力突出的小伙子(嗯?怎么是我?)去把Webpack、Babel、TypeScript、Sass\Less、目录结构、单元测试结构、代码规范、Review规范、发布规范这些梳理好,然后写一个标准的组件出来,最后再强调一下大家一定要按照规范认真维护组件,书写文档,编写单元测试。

从维护性上来讲,大家把组件都写在一个库里面,然后再用到的项目中直接引入,业务上的问题逐渐被分为组件问题还是项目问题,甚至有些需求可以用这个交互在组件库中有相似的,用那个组件就可以了,来反驳产品和设计😏。

就在大家用的不亦乐乎的时候,有一天发现,呀,我们的组件库怎么打包出来有10m啊😱!

然后找一个靠谱、能力突出的小伙子(没错又是我)就去查了下,这个库是谁引入的?这个组件不是已经有一个了吗?lodash不是这么用的呀!这个组件是干什么的,怎么没文档?

面对上百个业务组件,只能感叹一声业务迭代的可真快啊。

所以,大库维护固然有大库维护的好处和适用场景,大家能够有这样的抽象思维已经是技术上的突破了,现在只是遇到了另外一个问题,解它!

接触webpack的一些周边工具,比如analyzer 很容易可找出具体是什么包”霸占“了这么多的流量。

发现原来组件包中还有一些个组件,看上去不应该放在大库中进行维护,比如那种一次性组件,二次封装型组件。

因为这种组件可能会引入一个很大的第三方依赖,比如视频播放器、Banner Swiper 等。

对于这样的组件,最好的处理方式应该是创建一个独立的仓库,封装完善后,写好README,发布至内网NPM,供业务项目使用。

But you know ,这样做成本太高,如果有时间的话,我肯定…..balabala…(一般来说,如果你对程序员说一个更好的方案时,除非这个方案他有参与设计,否则大部分回复都是这样🙄)

当然组件大小这方面也可以通过很多其他方式解决,比如:异步加载,NPM引入,按需加载等等啦…那么,让我们谈谈下面另外一个很重要、又很容易被忽略的部分吧。

老板,我们今年沉淀了组件200+,其中有几个组件写的特别好,同时支撑了20+项目。

哇,这么棒!来给我看看你们写的组件长什么样?啊,这,这样来看看我们做的这个页面,这个页面里面用了这几个组件,balabala …

设计:听说你们已经沉淀了200+组件,能给我们看看有哪些组件吗?我们在下次设计的时候可以参考这些组件进行输出,减少沟通成本。

前端:@所有人 这个组件我们库里面有吗?有,CascadeSelect。哦,怎么用的?有文档吗?…….看下源码吧。well..😅

组件的说明及可索引性,其实仅次于组件的可用性,甚至更高。

试想下如果今天你写了个巨牛的组件,复用性、接口设计和交互设计都非常棒,但是你有什么渠道能让大家一下子就知道吗,难道你要专门为此拉大家开个会?来今天占用大家1个小时的宝贵时间,介绍下我今天写的巨牛组件。🤕

反过来想,如果我在写组件的时候,反正我这个组件也没啥亮点,别人应该也不会用到,就不用补充文档了吧,应该也没人会知道。哦豁,丸蛋🙃

索引组件,来给大家分享一张图:

如果有一天你团队的组件库也能像这样,一板一眼有图有真相,那该是多么幸福和享受的一件事情!

我也知道这样好啊!谁不知道!如果我有时间,我肯定会….balabala…


所以你的意思是让我们每写一个组件不但要补充文档,还要补充用法说明,还要截图!?

对,还要单独建库,还要考虑配置Webpack、Babel、TypeScript、Sass\Less、目录结构、单元测试结构、代码规范、Review规范、发布规范这些😎

哈!F*CK !!AWAY!!🤜

说这么多呢,主要是想带读者们一起思考,也是我写作的风格(喜欢讲故事),大部分内容其实是 前端er 都会遇到的问题。

接下就进入正题,说了一大堆问题,总得有点办法来解决吧!

先看看bit是怎么做的吧,bit首先自身有一定的编译能力,内置了webpack及一些插件式loader来解决React、Vue等编译问题。

对于我们团队来说,都是使用React,所以咱们就先从一个编译React的脚手架开始。

如果把每一个组件都作为单独的NPM项目发布,首先要考虑的是,前端一系列的编译环境。如果我有N个前端组件项目,每个前端组件库的webpack、babel这些都需要重复配置,那真是要头大的事情,我只是想写一个组件而已,为什么要考虑这些。所以我们的脚手架首先要具备一些基础的编译命令。

啊对了,脚手架还没有名字,那就暂时叫它:comp 吧 😷

  • comp new 处理按照模板新建一个标准组件

    • 初始化一个标准组件项目结构,所有接入所有 comp 命令

    • 初始化 Git 仓库

    • 初始化 CI/CD 云构建配置

  • comp start 处理日常开发,附加单个组件展示及调试能力

  • comp watch 处理 babel 及 scss 监听编译,用于 npm link 场景

  • comp babel 处理编译 npm 包

  • comp dev 处理监听编译 umd 包,用于代理调试

  • comp build 处理最后编译过程

    • webpack 编译 UMD 包

    • Babel 包

    • CI\CD过程中自动截图组件

    • CI\CD过程中自动生成 README

    • 其他Hook

  • comp test 处理 jest 单元测试

那么等组件初始化以后,目录结构就长这样:

项目结构中没有任何webpack\babel配置,当然如果有特殊配置需求,可以建立 comp.config.js 进行配置(此处借鉴很多已有的cli处理方式)。

这样处理的好处是,在项目初始化后,用户能见到的目录结构非常清晰明了,项目中不有很多允许配置的地方,所有组件的编译环境基本可以保证统一。

这都是些非常基础的功能,当然又是不可缺少的部分,这些基础命令我就不详细介绍了,重点在后面。

通过这几个问题来介绍功能:

你平时开发组件的流程是什么样子?

平时,一般就是根据设计稿,切分到组件后。

然后去创建组件,最后通过项目引入,一边看着一边开发啊。

你开发组件的时候对于你提供的Props是如何验证的?

最简单的给一个mock看看效果呗。

或者写一个单元测试?

那写Mock的过程算不算是在写Usage呢?

这个,应该也算吧,但是这些都是散落在各个项目里面,有些mock验证完就删掉了。

谁会闲的没事在开发的时候把这些补充到README里面去啊。

为什么他们不写文档?

这还用说?因为懒呗?

那你为啥不写?emm,那是因为….写文档这事儿吧,写了不一定有人看,还费时间呀!业务需求那么多!我要是有时间的话,我肯定….balabala…

OK,那我们来看下一个问题

一个好的组件文档需要那几部分?

开发组件背景,注意事项啥的,这个没啥太大的必要,有的组件需要的话就补充下,没有的话就不用补充。

主要需要的一些介绍有 :用法Props入参,最好能有个截图

还有安装、开发、编译的命令介绍得有吧。

锦上添花的话最好还能有几个badge,介绍下源码是TypeScript,下载量多少。

但是,要补充这些文档是在太麻烦了,要一个一个整理,Props这些信息,用的人可以在组件里面找到啊,我都有些注释和类型定义的呀!


完成一轮心灵拷问之后,就会发现在整个组件的开发过程中,开发者本人之所以对这个组件这么清楚,是因为开发者其实已经为自己写过一份README了。

  • 用法:组件开发过程中需要看到效果,写过一些mock数据,已经知道什么样的props传进去会产生什么样的效果
  • Props入参:组件有哪些Props,所代表的含义是什么,每个Props入参类型是什么,已经在TypeScript的Interface及注释中有体现
  • 截图:有mock数据还不知道长什么样?已经看过N多边了

有了这三个最重要的介绍后,相信大部分开发者也都能知道这个组件是怎么个情况了。

所以,如果我们能把上面这些数据都收集到,是不是就可以利用脚本 自动生成README文档 了呢?

用法 / Usage

要收集用法其实很简单,如果让组件有独立开发的能力,不就可以保留这些Usage的Mock数据了吗

有些人可能没理解我说的”组件独立开发的能力“是什么意思,我解释一下下:我们平时开发一个组件,一般都是把这个组件放置于某个页面中,一遍调试页面一遍调试组件。独立开发组件的意思是,直接启动一个页面,可以看到组件的样子,这个页面展示的就是围绕组件的所有信息。

所以在脚手架中,只要在 docs.ts 中书写需要调试组件相关的mock数据,页面就可以展示出组件的样子,同时这些mock数据可以保留作为 README 文档数据。

另外,如果保证这份demo的接口输出统一规范,还可以支持直接生成在 CodePen,Riddle 这些在线编辑的代码内容。

试想下,你的README中如果出现一段 : 点击立即体验 ,跳转过去后可以在线编辑实时看到效果,那对于任何看到你组件的同学来说都是一种享受 😉

组件参数 / Props

要收集这部分数据就比较复杂了,我们需要深入分析 TypeScript AST 语法树,提取出其中组件 props 的类型以及对于Interface的注释内容。

经过一番github,终于找到可以实现一个可以处理这件事情的小众库 react-docgen-typescript

在开发过程中,因为对一些注释及类型输出与我预期的不太一样,所以我fork后做了一些修改,已经可以完成对一个完整组件的 Props 做分析后输出一份 typefile.json 。

同样的,通过基于该能力,可以扩展为webpack插件react-docgen-typescript-loader,为组件的静态属性中添加__docInfo属性,来声明其属性内容,于是组件开发过程变可以实现以下效果:

截图 / Preview

有了组件,有了demo,还愁没有截图吗?

直接在构建过程中用 puppeteer ,读取运行docs.ts 渲染出组件,进行截图,然后随着云构建CD过程发到CDN,就完事了!


最后,README中加入一些特殊标记,在云构建过程中进行README替换生成就可以啦!并不会影响README本身要叮嘱的内容。

最后,Duang !一份完整,漂亮,详细的文档就生成好了,整个过程我们并没有特意写过什么README方面的内容,一切都是非常轻松标准的进行输出。

在上面的一整套复杂的过程中,看上去最后好像我只得到了一个自动生成README的功能。但实际上呢,其实README只是一个顺带的产物。

整个过程中,我已经拿到了这个组件的所有我想要拿到的数据,它的Props,Usage,Preview,版本信息,包名,甚至构建过程会同步发布该组件的UMD CDN包及NPM包。

接下来,就可以围绕这些数据和工具,建立和扩展很多功能和平台。

举几个栗子:

  • 建立一个bit 一样的,组件平台,把团队内的组件收集起来,统一在平台展示及索引
  • 根据拿到Props类型信息做可视化的搭建平台,把Props的传参直接交给用户设置,根据不同数据类型提供不同的Form Setter
  • 看似组件都分布在不同的库中,却可以通过组件cli做统一的构建处理
  • 非常轻松接入 微前端 框架,因为所有组件的发布构建都是标准的构建协议
  • 通过统计组件发布次数,下载次数,关联bug数评估代码质量

目前在我们团队,已经使用该工具产出 30+ 的可用组件,并且发布组件已经成功接入到我们已有的可视化编辑器中。

看一眼结合可视化设置面板后的效果吧:

我发现只要实现过程中,没有给开发者带来太多的工作量,又能带来实时可以看到的效果,开发者会很乐意为那些Props做一番解释和修饰😊。

我们团队目前产出的组件看起来一片通透,整齐明了。

另外,因为脚手架出世时间还短,需要完善的地方还很多,还没有符合公司规定的开源标准,就不拿出来献丑啦😊,不过我觉得整体思路更有参考价值!

我是一个热爱生活的前端工程师! Yooh!🤠

Tumax-复合状态管理(显隐问题)

在 Tumax 项目中,作为编辑器存在非常多的“视图交互”,其中关于显隐就是一块状态复杂、由多重因素影响的交互重灾区。

随着项目的不断迭代,业务需求的不断增多,隐藏的交互事件也逐渐增多(墙体隐藏,吊顶隐藏,地面模型隐藏,硬装下隐藏,选择房间后隐藏,挂墙、门窗跟随隐藏,不同路由显隐不同),这么多显隐逻辑混在一起,逐渐发现我们以前原有的显隐逻辑变得异常混乱。

举个简单的例子:

在户型导航的逻辑中,要求对选中的房间以外的所有模型数据都隐藏,当我们执行这样的操作后,表现正常

在墙体隐藏的逻辑中,要求遮挡观察实现的墙体全部隐藏,当不遮挡视线后再恢复显示

  1. 选择单房间,转动相机,被户型导航隐藏的墙体显示了出来。
  2. 选择单房间,转动相机,返回完整户型,被摄像机隐藏的墙体显示了出来。
  3. 选择单房间,上下移动相机,其他房间的地面模型被显示了出来。
  4. 选择单房间,切换到顶面路由,其他房间的顶面模型被显示了出来。
  5. 在 2D、3D 同时存在的页面下,转动相机,2D 下的模型显隐跟随 3D 摄像机转动变化。

在上述例子中,可以感受到如果单凭数据模型中的一个visible状态,来处理模型的显隐逻辑是远远不够的。

那么究竟应该如何解决这么多交互混合应用之后的显隐逻辑呢?

吉德林法则:把难题清清楚楚地写出来,便已经解决了一半。只有先认清问题,才能很好地解决问题

首先,遇到问题需要搞清楚问题到底是什么?

上面列举的问题例子都是属于个别 Bug,如果针对于这些 Bug 做对应的处理修复,就会陷入无休止的 Bug 循环,改了户型导航影响墙体隐藏,改了墙体隐藏影响路由隐藏,真正变成“捉襟见肘”。

所以,为了解决根本问题,需要分析得出最根本的印象因素。通过验证每个 case,我列出以下几个会影响任意模型显隐的影响因素。也就是说,在项目中的任意一个物体的显隐,都是以下几个因素共同作用下的结果。


影响因素:

通用生效(C):

  1. 总开关
  2. 选中的户型导航

仅 3D 生效(S3):

  1. 3D 开关
  2. 摄像机

仅 2D 生效(S2)

  1. 2D 开关

下面会列举出项目中所有的显隐 Case,以下所说的所有家具,包含组合家具。

公式解释:

S2.1:boolean ==> 2D影响因素1(2D 开关),值可能为true or false

C1.1:false ==> 通用影响因素1(总开关),值为false,也就是无论如何都不显示

S2.1:? && C.2:? ==> 显隐受 2D开关(S2.1)及户型导航(C.2)共同作用,只有两个值都为true时才显示

2D 设计路由

  • 地面家具:S2.1:? && C.2:? ==> 在 2D 路由下,地面家具显隐需要地面家具开关户型导航都为 true
  • 吊顶家具:S2.1:? && C.2:?
  • 天花家具:S2.1:false

3D 设计路由

  • 地面家具:C.2:? && S3.2:?
  • 地面家具:C.2:? && S3.2:?
  • 墙体显隐:C.2:?
  • 墙体透明:S3.1:? && S3.2:? ==> 3D 墙体分为透明及隐藏,透明有 3D 开关及摄像机决定

硬装设计路由

硬装路由下,存在 2D 场景与 3D 场景同时存在的情况,且显隐规则不同。所以需要分别列出他们的显隐规则。

3D 场景通用规则

  • 地面家具:C.2:? && S3.1:? && S3.2:? ==> 3D 地面家具显示需要 家具开关、户型导航、摄像机 都为true
  • 吊顶家具:C.2:? && S3.1:? && S3.2:?
  • 墙体显隐:C.2:?
  • 墙体透明:S3.1:? && S3.2:?

2D 场景

顶面(硬装)路由:

  • 地面家具:S2.1:false ==> 顶面路由下,不显示 2D 家具
  • 吊顶家具:S2.1:? && C.2:?

地面(硬装)路由:

  • 地面家具:S2.1:? && C.2:?
  • 吊顶家具:S2.1:false ==> 地面路由下,不显示顶面路由

墙面(硬装)路由:

  • 地面家具:S2.1:fasle ==> 墙面路由下,不显示任何家具
  • 吊顶家具:S2.1:false

渲染路由

3D 场景

  • 地面家具:C.2:? && S3.1:? && S3.2:? ==> 3D 地面家具显示需要 家具开关、户型导航、摄像机 都为true
  • 吊顶家具:C.2:? && S3.1:? && S3.2:?
  • 墙体显隐:C.2:?
  • 墙体透明:S3.1:? && S3.2:?

2D 场景

地面模式:

  • 地面家具:C.2:?
  • 吊顶家具:S2.1:false

顶面模式:

  • 地面家具:S2.1:fasle
  • 吊顶家具:C.2:?

理清楚了上述所有的显隐规则后,所要解决的就已经变得清晰明了 了。原来,场景中的每个物体的显隐visible都取决于这几种影响因素的共同作用。

那么就需要有一套显隐规则来作为visible的计算依赖,代码实现:

interface IOptions {
  camera?: boolean;
  navigation?: boolean;
  switch?: boolean;
  switch2D?: boolean;
  switch3D?: boolean;
}

const DEFAULT_OPTIONS: IOptions = {
  camera: true,
  navigation: true,
  switch: true,
  switch2D: true,
  switch3D: true
};

export default class VisibleRuler {
  public camera: boolean; // 相机位置
  public navigation: boolean; // 户型导航
  public switch: boolean; // 总开关控制

  public switch2D: boolean; // 2D开关
  public switch3D: boolean; // 3D开关

  constructor(options?: IOptions) {
    this.init(options);
  }

  public get visible2D(): boolean {
    return this._navigation && this._switch && this._switch2D;
  }

  public get visible3D(): boolean {
    return this._navigation && this._switch && this._camera && this._switch3D;
  }

  public init(options: IOptions = {}) {
    options = {
      ...options,
      ...DEFAULT_OPTIONS
    };
    Object.keys(options).forEach(key => {
      if (Object.hasOwnProperty.call(this, key)) {
        this[key] = options[key];
      }
    });
  }

  public copy(visibleRuler: VisibleRuler) {
    Object.keys(DEFAULT_OPTIONS).forEach(key => {
      if (Object.hasOwnProperty.call(this, key)) {
        this[key] = visibleRuler[key];
      }
    });
  }
}

如果原来在数据模型中的visible,如下:

class Model {
  position: Vector3;
  scale: Vector3;
  rotate: Euler;
  matrix: Matrix4;
  visible: boolean = true;
  ...
}

就需要变为:

class Model {
  position: Vector3;
  scale: Vector3;
  rotate: Euler;
  matrix: Matrix4;
  visibleRuler: VisibleRuler = new VisibleRuelr();

  get visible3D(): boolean {
    return this.visibleRuler.visible3D;
  }

  get visible2D(): boolean {
    return this.visibleRuler.visible2D;
  }
}

接着再加入Mobx,实现数据响应:

import { action, computed, observable } from "mobx";

export default class VisibleRuler {
  @observable
  public camera: boolean; // 相机位置
  @observable
  public navigation: boolean; // 户型导航
  @observable
  public switch: boolean; // 总开关控制

  @observable
  public switch2D: boolean; // 2D开关
  @observable
  public switch3D: boolean; // 3D开关

  constructor(options?: IOptions) {
    this.init(options);
  }

  @computed
  public get visible2D(): boolean {
    return this._navigation && this._switch && this._switch2D;
  }

  @computed
  public get visible3D(): boolean {
    return this._navigation && this._switch && this._camera && this._switch3D;
  }

  @action
  public init(options: IOptions = {}) {
    options = {
      ...options,
      ...DEFAULT_OPTIONS
    };
    Object.keys(options).forEach(key => {
      if (Object.hasOwnProperty.call(this, key)) {
        this[key] = options[key];
      }
    });
  }
}

在改造完成后,那么,如果现在需要改变一个模型的显隐需要如何去做呢?

如摄像机高度高于当前楼层高度,则修改所有吊顶类模型的visibleRuler.camera,紧接着 Mobx 中的computed将会被触发重新计算,如果计算结果与上次的显隐状态不同,Mobx 将会触发所有订阅的更新,用于同步到对应的 2D、3D 需要显示显隐状态。

import homeModel from "../store";

const toggleCeilingModel = (b: boolean) => {
  const ceilingModels: Model[] = homeModel.curLevel.getCeilingModels;
  ceilingModels.forEach(model => model.visibleRuler.setCamera(b));
};

以上内容均为实际项目中所遇到的问题,解决思路过程及时间占比大概分为:

  • 梳理问题,帮助产品理清显隐需求点(40%)
  • 编写代码(10%)
  • 测试、修改 影响点(50%)

这个问题在项目中反复出现、反复修改,每次“捉襟见肘”的修改都可能会出现下一次的 Bug,甚至在项目代码中出现以下注释:

从解决问题的时间占比可以看出,在解决一些问题的过程中,编写代码的部分反而是占比最少的。

大量的时间是花费在与产品、测试沟通,编写完成后对项目的影响点测试、修改上面。

有人会问,为什么不在一开始设计的时候就做好呢?这样后面不就没这个问题了?

我认为设计是 Line,而非 Point。设计是一个持续的过程,有新的需求过来,在当前的设计下无法满足需求,就需要思考进一步的设计,在不断的理解中寻求好的设计与解决方案。

只有这样作为工程师才有差异化的竞争力与价值点,只有这样的工程师才能拥有自己的技术人力模型。

对于项目中的技术债务、项目中的污点,想要称为一名高级/资深工程师应该具有拥抱变化、解决根本问题、思考更优设计的能力和动力。

JavaScript 常见设计模式

设计模式,这一话题一直都是程序员谈论的”高端”话题之一。许多程序员从设计模式中学到了设计软件的灵感和解决方案。

有人认为设计模式只在 C++或者 Java 中有用武之地,JavaScript 这种动态语言根本就没有设计模式一说。

那么,什么是设计模式?

设计模式:在面向对象软件设计过程中,针对特定问题的简洁而优雅的解决方案。

通俗一点讲,设计模式就是在某种场合下对某个问题的一种解决方案。如果再通俗一点说,设计模式就是给面向对象软件开发中的一些好的方法,抽象、总结、整理后取了个漂亮,专业的名字

其实很多设计模式在我们日常的开发过程中已经有使用到,只是差一步来真正意识、明确到:”哦!我用 xx 设计模式来完成了这项业务”!

而下次在遇到同样问题时,便可以快速在脑海里确定,要使用 xx 设计模式完成任务。

对此,我整理了一些前端常用到的一些设计模式。

单例模式,也叫单子模式,是一种常用的软件设计模式。 在应用这个模式时,单例对象的类必须保证只有一个实例存在。 许多时候整个系统只需要拥有一个的全局对象,这样有利于我们协调系统整体的行为。

单例模式作为各端语言一个比较常见的设计模式,一般用于处理在一个生命周期中仅需要存在一次即可完成任务的内容来提升性能及可用性。非常常见的用于后端开发中,如连接 Redis、创建数据库连接池等。

在 JavaScript 中的应当如何应用呢?

在 JavaScript 中什么情况下会用到单例模式呢?

import Router from "vue-router";

export default new Router({
  mode: "hash",
  routes: [
    {
      path: "/home",
      name: "Home",
      component: Home,
      children: []
    }
  ]
});

这就是在日常开发中最常用到的单例模式,在整个页面的生命周期中,只需要有一个Router来管理整个路由状态,所以在route中直接export已经实例化后的对象,那么在任何模块中,只要引入这个模块都可以改变整个路由状态。

通过这种方式引入有一个小的问题就是:所用到的单例内容,全部是在调用方引入过程中就已经完成实例化的,一般来说调用方的引入也都是非动态引入,所以页面一开始加载的时候便已经加载完毕。

上述这种用法是属于利用 JS 模块化,完成的一种变异单例,那么一个标准的单例写法应该是什么样的呢?

export default class LoginDialog {
  private static _instance: LoginDialog;
  private component: VueComponent;

  public static getInstance() {
    if (!this._instance) {
      this._instance = new LoginDialog();
    }

    return this._instance;
  }

  private constructor() {
    // 创建登录组件Dom
    this.component = createLoginComponent();
  }

  public show() {
    this.component.show();
  }

  public hide() {
    this.component.hide();
  }
}

// 调用处
const loginDialog = LoginDialog.getInstance();
loginDialog.show();

以上是一个简单的登录弹窗组件的单例实现,这样实现后有以下几个好处:

  • 避免多次创建页面 Dom 节点
  • 隐藏、重新打开保存上次输入结果
  • 调用简单,随处可调
  • 按需创建,第一次调用才被创建

在单例的实例化过程中,假若需要异步调用后才能创建实例结果,如:

export default class LoginDialog {
  private static _instance: LoginDialog;
  private component: VueComponent;
  private loginType: any;

  public static async getInstance() {
    if (!this._instance) {
      const loginData = await axios.get(url);
      this._instance = new LoginDialog(loginData);
    }

    return this._instance;
  }

  private constructor(loginType) {
    this.loginType = loginType;
    // 创建登录组件Dom
    this.component = createLoginComponent();
  }
}

// 调用方1
(async () => {
  await LoginDialog.getInstance();
})();

// 调用方2
(async () => {
  await LoginDialog.getInstance();
})();

像这样的代码中,返回的结果将会是LoginDialog被实例化两次。所以遇到异步调用这样的异步单例,属于 Js 的一种比较特殊的实现方式。

应该尽量的避免异步单例的情况发生,但若一定需要这样调用,可以这样写。

export default class LoginDialog {
  private static _instance: LoginDialog;
  private static _instancePromise: Promise;

  private component: VueComponent;
  private loginType: any;

  public static async getInstance() {
    if (!this._instancePromise) {
      this._instancePromise = axios.get(url);
    }

    const loginData = await this._instancePromise;

    if (!this._instance) {
      this._instance = new LoginDialog(loginData);
    }

    return this._instance;
  }

  private constructor(loginType) {
    this.loginType = loginType;
    // 创建登录组件Dom
    this.component = createLoginComponent();
  }
}

策略模式,定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。

简单来讲,就是完成一个方法过程中,可能会用到一系列的工具,通过外部传入区分类别的参数来达到使用不同方法的封装。

举一个老例子,公司的年终奖计算,A 为 3 月薪,B 为 2 月薪,C 为 1 月薪:

const calculateBouns = function(salary, level) {
  if (level === "A") {
    return salary * 3;
  }
  if (level === "B") {
    return salary * 2;
  }
  if (level === "C") {
    return salary * 1;
  }
};

// 调用如下:
console.log(calculateBouns(4000, "A")); // 16000
console.log(calculateBouns(2500, "B")); // 7500

上述代码中有几个明显的问题:

  • calculateBouns函数内容集中
  • calculateBouns函数扩展性低
  • 算法复用性差,如果在其他的地方也有类似这样的算法的话,但是规则不一样,我们这些代码不能通用

一个基于策略模式的程序至少由 2 部分组成.

  1. 一组策略类,策略类封装了具体的算法,并负责具体的计算过程。
  2. 环境类 Context,该 Context 接收客户端的请求,随后把请求委托给某一个策略类。
class Bouns {
  salary: number = null; // 原始工资
  levelObj: IPerformance = null; // 绩效等级对应的策略对象

  constructor(salary: number, performanceMethod: IPerformance) {
    this.setSalary(salary);
    this.setLevelObj(performanceMethod);
  }

  setSalary(salary) {
    this.salary = salary; // 保存员工的原始工资
  }
  setLevelObj(levelObj) {
    this.levelObj = levelObj; // 设置员工绩效等级对应的策略对象
  }
  getResult(): number {
    if (!this.levelObj || !this.salary) {
      throw new Error("Necessary parameter missing");
    }
    return this.levelObj.calculate(this.salary);
  }
}
interface IPerformance {
  calculate(salary: number): number;
}

class PerformanceA implements IPerformance {
  calculate(salary) {
    return salary * 3;
  }
}

class PerformanceB implements IPerformance {
  calculate(salary) {
    return salary * 2;
  }
}

class PerformanceC implements IPerformance {
  calculate(salary) {
    return salary * 1;
  }
}

console.log(new Bouns(4000, new PerformanceA()).getResult());
console.log(new Bouns(2500, new PerformanceB()).getResult());

这种做法能够具有非常高的可复用性及扩展性。写过 ng 的读者,看到这里是否觉得非常眼熟?

没错,ng 所提倡的依赖注入就是使用了策略模式的设计思路。

迭代器模式:提供一种方法顺序一个聚合对象中各个元素,而又不暴露该对象内部表示。

迭代器模式其实在前端编码中非常常见,因为在 JS 的Array中已经提供了许多迭代器方法如:map,reduce,some,every,find,forEach等。

那是否能理解为,迭代器模式的作用就是为了让我们减少 for 循环呢?

来先看一个面试题:

const removeCharacter = str => str.replace(/[^\w\s]/g, " ");
const toUpper = str => str.toUpperCase();
const split = str => str.split(" ");
const filterEmpty = arr => arr.filter(str => !!str.trim().length);

const fn = compose(
  removeCharacter,
  toUpper,
  split,
  filterEmpty
);

fn("Hello, to8to World!"); // => ["HELLO","TO8TO","WORLD"]

// 请实现`compose`方法来达到效果

这道题的内容虽然是在考察函数式编程的理解,但却蕴含着迭代器模式的设计思路,利用迭代器模式,将一个个的方法融合成为一个新的方法。其中的融合方法又可以作为参数替换,来达到不同效果。

那么除了这种用法,有没有日常项目中 “更常用” 的场景或用途呢?

常见的,如验证器:

// 将数组中的every方法重新写一下,让读者更清晰
const every = (...args: Array<(args: any) => boolean>) => &#123;
  return (str: string) => &#123;
    for (const fn of args) &#123;
      if (!fn(str)) &#123;
        return false;
      &#125;
    &#125;

    return true;
  &#125;;
&#125;;

const isString = (str: string): boolean => typeof str === "string";
const isEmpty = (str: string): boolean => !!`$&#123;str&#125;`.trim().length;
const isEmail = (str: string): boolean =>
  /^[\w.\-]+@(?:[a-z0-9]+(?:-[a-z0-9]+)*\.)+[a-z]&#123;2,3&#125;$/.test(str);
const isPhone = (str: string): boolean => /^1\d&#123;10&#125;$/.test(str);
const minLength = (num: number): ((str: string) => boolean) => &#123;
  return str => `$&#123;str&#125;`.trim().length > num;
&#125;;

const validatorEmail = every(isString, isEmpty, minLength(5), isEmail);
const validatorPhone = every(isString, isEmpty, minLength(5), isPhone);

console.log(validatorEmail("wyy.xb@qq.com"));
console.log(validatorPhone("13388888888"));

可以看到,不同的验证类型可以相互组合,可添可删可自定义。

以上是一个简单的对字符串的验证应用,同样的迭代设计可以应用在更复杂的场景中,如在游戏应用中:

  • 对一个实体墙体绘制过程中,是否合法(是否穿过门窗,是否穿过弧形墙,是否过短,是否夹角过小)
  • 移动物体时,对物体模型做碰撞吸附过程计算位移(与附近物体、墙体吸附位移,与墙体碰撞位移,与其他物体叠放位移)

发布-订阅模式,他定义了一种一对多的依赖关系,即当一个对象的状态发生改变的时候,所有依赖他的对象都会得到通知。

发布-订阅模式(观察者模式),在编程生涯中是非常常见并且出色的设计模式,不论前端、后端掌握好了这一设计模式,将会为你的职业生涯增加一大助力。

我们常常听说的各种 Hook,各种事件纷发,其实都是在使用这一设计模式。

作为一名前端开发人员,给 DOM 节点绑定事件可是再频繁不过的事情。比如如下代码

document.body.addEventListener(
  "click",
  function() &#123;
    alert(2333);
  &#125;,
  false
);
document.body.click();

这里我们订阅了 document.body 的 click 事件,当 body 被点击的时候,他就向订阅者发布这个消息,弹出 2333。当消息一发布,所有的订阅者都会收到消息。

那么内部到底发生了什么?来看看一个简单的观察者模式的实现过程:

const event = &#123;
  peopleList: [],
  addEventListener: function(eventName, fn) &#123;
    if (!this.peopleList[eventName]) &#123;
      //如果没有订阅过此类消息,创建一个缓存列表
      this.peopleList[eventName] = [];
    &#125;
    this.peopleList[eventName].push(fn);
  &#125;,
  dispatch: function() &#123;
    let eventName = Array.prototype.shift.call(arguments);
    let fns = this.peopleList[eventName];
    if (!fns || fns.length === 0) &#123;
      return false;
    &#125;
    for (let i = 0, fn; (fn = fns[i++]); ) &#123;
      fn.apply(this, arguments);
    &#125;
  &#125;
&#125;;

了解到实现的原理后,那么在日常的开发过程中,要如何真正利用发布-订阅模式处理业务功能呢?

首先来说实现过程,在日常开发中,不会直接去书写这样一大堆代码来实现一个简单的观察者模式,而是直接会借助一些库来方便实现功能。

import EventEmitter3 from "EventEmitter3";

export default class Wall extends EventEmitter3 &#123;&#125;

const wall = new Wall();

wall.addEventListener("visibleChange", () => &#123;&#125;);
wall.on("visibleChange", () => &#123;&#125;); // addEventListener 别名

// 一次时间后释放监听
wall.once("visibleChange", () => &#123;&#125;);

wall.removeEventListener("visibleChange", () => &#123;&#125;);
wall.off("visibleChange", () => &#123;&#125;); // removeEventListener 别名

wall.emit("visibleChange");

发布-订阅模式是在编程过程中非常出色的设计模式,在日常业务开发中方便高效的帮我们解决问题的同时,也存着这一些坑点,需要格外注意:

import EventEmitter3 from "EventEmitter3";

export default class Wall extends EventEmitter3 &#123;&#125;
export default class Hole extends EventEmitter3 &#123;
  public relatedWall(wall: Wall) &#123;
    wall.on("visibleChange", wall => (this.visible = wall.visible));
  &#125;
&#125;

const wall = new Wall();
let hole = new Hole();
hole.relatedWall(wall);

// hole.destroy();
hole = null;

如上,我实现了一个简单的功能,当墙体隐藏时,墙体上的洞也通过观察者模式跟随隐藏。

后来,我想要删除这个 墙洞。按照 Js 的常规用法,不用特意处理释放内存,Js 的垃圾回收机制会帮我们处理好内存。

但是,这里虽然设置了 hole 为null,hole 却在内存中依旧存在!

因为垃圾回收机制中,不论是 引用计数垃圾收集 还是 标记-清除 都是采用引用来判断是否对变量内存销毁。

而上述代码中,wall 自身原型链中的events已经有对 hole 有所引用。如果不清除他们之间的引用关系,hole 在内存中就不会被销毁。

如何做到既优雅又快速的清除引用呢?

import EventEmitter3 from "EventEmitter3";

/**
 * 抽象工厂方法,执行on,并返回对应off事件
 * @param eventEmit
 * @param type
 * @param fn
 */
const observe = (
  eventEmit: EventEmitter3,
  type: string,
  fn: (...args) => any
): (() => void) => &#123;
  eventEmitter.on(type, fn);
  return () => eventEmitter.off(type, fn);
&#125;;

export default class Wall extends EventEmitter3 &#123;&#125;
export default class Hole extends EventEmitter3 &#123;
  private disposeArr: Array<() => void> = [];

  public relatedWall(wall: Wall) &#123;
    this.disposeArr.push(
      observe(wall, "visibleChange", wall => (this.visible = wall.visible))
    );
  &#125;

  public destroy() &#123;
    while (this.disposeArr.length) &#123;
      this.disposeArr.pop()();
    &#125;
  &#125;
&#125;

const wall = new Wall();
let hole = new Hole();
hole.relatedWall(wall);

hole.destroy();
hole = null;

如上,在 hole 对 wall 进行订阅时,利用封装的工厂类方法,同时返回了这个方法的释放订阅方法

并加入到了当前类的释放数组中,当 hole 需要销毁时,只需简单调用hole.destroy(),hole 在实例化过程中的所有订阅事件将全部会被释放。 Bingo!

适配器模式:是将一个类(对象)的接口(方法或属性)转化成客户希望的另外一个接口(方法或属性),适配器模式使得原本由于接口不兼容而不能一起工作的那些类(对象)可以一些工作。

适配器模式在前端项目中一般会用于做数据接口的转换处理,比如把一个有序的数组转化成我们需要的对象格式:

const arr = ["Javascript", "book", "前端编程语言", "8月1日"];
function arr2objAdapter(arr) &#123;
  // 转化成我们需要的数据结构
  return &#123;
    name: arr[0],
    type: arr[1],
    title: arr[2],
    time: arr[3]
  &#125;;
&#125;

const adapterData = arr2objAdapter(arr);

在前后端的数据传递的时候会经常使用到适配器模式,如果后端的数据经常变化,比如在某些网站拉取的数据,后端有时无法控制数据的格式。

所以在使用数据前,最好能够定义前端数据模型通过适配器解析数据接口。 Vmo就是一个我用于做这类工作的数据模型所开发的微型框架。

另外,对于一些面向对象的复杂类处理时,为了使方法复用,同样可能会使用到适配器模式。

// 正常模型
class Model &#123;
  public position: Vector3;
  public rotation: number;
  public scale: Vector3;
&#125;

// 横梁立柱
class CubeBox &#123;
  public position: Vector2;
  public rotation: number;
  public scale: Vector3;
  public heightToTop: number;
  public heightToBottom: number;
&#125;

const makeVirtualModel = (cube: CubeBox): Model => &#123;
  const model = new Model();
  model.position = new Vector3(
    cube.position.x,
    cube.heightToBottom,
    cube.position.y
  );
  model.rotation = cube.rotation;
  model.scale = cube.scale.clone();

  return model;
&#125;;

const adsorbModel = (model: Model): Vector3 => &#123;&#125;;

const model = new Model();
const cube = new CubeBox();

// 模型吸附偏移向量
const modelOffset = adsorbModel(model);

// 如果CubeBox,立柱同样需要使用吸附功能,但成员变量类型不同,就需要先适配后再计算
const cubeOffset = adsorbModel(makeVirtualModel(cube));
const compose = (...args) => &#123;
  return str => args.reduce((prev, next) => next.call(null, prev), str);
&#125;;
const compose = (...funcs) =>
  funcs.reduce((prev, next) => (...args) => next(prev(...args)));

Vmo前端数据模型设计

Vmo 是一个用于前端的数据模型。解决前端接口访问混乱,服务端数据请求方式不统一,数据返回结果不一致的微型框架。

Vmo 主要用于处理数据请求,数据模型管理。可配合当前主流前端框架进行数据模型管理 Vue,React,Angular。

能够有效处理以下问题:

  • 接口请求混乱,axios.get...随处可见。
  • 数据管理混乱,请求到的数据结果用完即丢、拿到的数据直接放进Store
  • 数据可靠性弱,不能保证请求数据是否稳定,字段是否多、是否少。
  • Action方法混乱,Action中及存在同步对Store的修改,又存在异步请求修改Store
  • 代码提示弱,请求到的数据无法使用TypeScript进行代码提示,只能定义 any 类型。
  • 无效字段增多,人员变动,字段含义信息逐步丢失,新业务定义新字段。
  • 项目迁移繁重,项目重构时,对字段不理解,重构过程功能点、数据丢失。

随着现有大前端的蓬勃发展,Vue、React 等框架不断流行,RN、Weex、Electron 等使用 JS 开发客户端应用的不断发展,Taro、mpVue、CML 等新型小程序框架的不断创新。JavaScript 将变得更加流行与多样,使用 JS 同构各端项目将不再是梦。

JS 的灵活在赋予大家方便的同时也同样存在着一些问题,同样实现一个数据获取到页面渲染的简单操作,可能就会有非常多的写法。正常的,在 Vue 中,可能会直接这样写:

const methods = &#123;
  /**
   * 获得分类信息
   */
  async getBarData() &#123;
    try &#123;
      const &#123; data &#125; = await axios.get(url, params);

      return data;
    &#125; catch (e) &#123;
      console.error("something error", e);
    &#125;
  &#125;
&#125;;

这样的做法在功能上讲没什么问题,但在新增一些其他动作后,这样的做法就变得非常难以管理。

比如,需要在请求中加入一些关联请求,需要获取一个商品页的列表,查询参数包含,分页参数(当前页,查询数),分类 Id,搜索内容,排序方式,筛选项。

在执行该请求时,发现分类 Id 也需要另外一个接口去获取。于是代码成了:

const params = &#123;
  sort: -1,
  search: "",
  filter: "",
  page: &#123;
    start: 1,
    number: 10
  &#125;
&#125;;
const methods = &#123;
  /**
   * 获得商品列表
   */
  async getGoodsData() &#123;
    try &#123;
      const &#123; data &#125; = await axios.get(url.goodsType); // 获取所有分类Id
      const &#123; id: typeId &#125; = data;
      const res = await axios.get(url.goods, &#123; ...params, typeId &#125;); // 获取商品

      return res.data;
    &#125; catch (e) &#123;
      console.error("something error", e);
    &#125;
  &#125;
&#125;;

这样看上去貌似是完成了这个业务,但其实在业务不断变化的环境下,这样直接在组件中书写接口请求是非常脆弱的。

比如以下问题:

  • 返回结果中,有字段需要单独处理后才能使用。比如:后端可能返回的一个数组是,隔开
  • 返回结果中,有字段在某种情况下缺失
  • 接口地址发生变动
  • 随着业务变动,接口字段需要改动
  • 其他组件需要使用同样这份数据,但不能保证组件调用顺序
  • 部分接口数据需要前端缓存
  • 接口存储方式发生变化。比如:有网络走接口,没网络走 LocalStorage
  • 前端项目框架迁移,接口不变。Vue 转 React?Vue 转小程序?

为了让读者更容易理解我所说的痛点,我列举了几个反例场景来说明:

const methods = &#123;
  /**
   * 获取过滤项信息
   */
  async getFilterInfo() &#123;
    try &#123;
      const &#123; data: filterInfo &#125; = await axios.get(url.goodsType); // 获取所有分类Id
      // filterInfo.ids => "2,3,5234,342,412"
      filterInfo.ids = filterInfo.ids.map(id => id.split(","));

      return filterInfo;
    &#125; catch (e) &#123;
      console.error("something error", e);
    &#125;
  &#125;
&#125;;

在这个例子中,获取过滤项信息中返回的结果信息假设为:

&#123;
  "ids": "2,3,5234,342,412",
  ...
&#125;

在数据解析中,就需要处理为前端接受的数组,类似的解析还有非常多。

也许现在看这段代码无关痛痒,但若每次调用这个接口都需要这样处理,长期处理类似字段。甚至有很多开发者在一开始拿到这个字段都会暂时不去处理,到用到的地方再处理,每用一次处理一次。

那想想该是多么非常恶心的一件事情。

如果使用Vmo会在数据模型开始时,就使用load()来对数据做适配,拿到的数据能够稳定保证是我们所定义的那种类型。

// component1
// 需要使用 Goods 数据

const mounted = async () => &#123;
  const goods = await this.getGoodsData();
  this.$store.commit("saveGoods", goods); // 在store中存储

  this.goods = goods;
&#125;;

const methods = &#123;
  /**
   * 获得商品列表
   */
  async getGoodsData() &#123;
    try &#123;
      const &#123; data &#125; = await axios.get(url.goodsType); // 获取所有分类Id
      const &#123; id: typeId &#125; = data;
      const res = await axios.get(url.goods, &#123; ...params, typeId &#125;); // 获取商品

      return res.data;
    &#125; catch (e) &#123;
      console.error("something error", e);
    &#125;
  &#125;
&#125;;
// component2
// 也需要使用 Goods 数据

const mounted = async () => &#123;
  const goods = this.$store.state.goods;

  this.goods = goods;
&#125;;

在这个例子中,简单描述了两个组件代码(也许看上去很 low,但这种代码确实存在),他们都会需要使用到商品数据。按照正常流程组件组件的加载流程可能是

component1->component2

这样的顺序加载,那么上面这段是可以正常运行的。但假若业务要求,突然有一个component3要在两个组件之前加载,并且也需要使用商品数据,那么对于组件的改动是非常头疼的(因为实际业务中,可能你的数据加载要比这里复杂的多)。

小明是一位前端开发人员,他与后端人员愉快的配合 3 个月完成了一款完整的 H5 SPA 应用。

业务发展的很快,又经过数十次迭代,他们的日活量很快达到了 5000,但存在 H5 的普遍痛点,用户留存率不高。

于是产品决定使用小程序重构当前项目,UI、后端接口不用改变。

小明排期却说要同样 3 个月,对此产品非常不理解,认为当初从无到有才用了 3 个月,现在简单迁移为什么也需要这么久。

小明认为,虽然接口、UI 不变。但小程序与 H5 之间存在语法差异,为了考虑后续 H5、小程序多端迭代保持统一,需要花时间在技术建设上,抽离出公共部分,以减轻后续维护成本。

产品非常不理解问开发,如果不抽离会怎么样,能快点吗?就简单的复制过来呢?于是小明为难之下,非常不满的说那可能 2 周。

Deal!就这么办。

2 周开发,1 周测试,成功上线!

第 4 周,随着需求迭代,后端修改了一个接口的返回内容,前后端联动上线后发现之前的 H5 页面出现大面积白屏。

事后定位发现,由于后端修改导致 H5 数据解析出现 JS 异常。项目组一致认为是由于前段人员考虑不够全面造成的本次事故,应该由小明承担责任。

5 个月后,小明离职…

在业务场景中假设有一段接口返回的 Json 如下:

&#123;
  "c": "0",
  "m": "",
  "d": &#123;
    "bannerList": [
      &#123;
        "bannerId": "...",
        "bannerImg": "...",
        "bannerUrl": "...",
        "backendColor": null
      &#125;
    ],
    "itemList": [
      &#123;
        "obsSkuId": "...",
        "obsItemId": "...",
        "categoryId": null,
        "itemName": "...",
        "mainPic": "...",
        "imgUrlList": null,
        "suggestedPriceInCent": null,
        "priceInCent": null,
        "obsBrandId": "...",
        "width": null,
        "height": null,
        "length": null,
        "bcsPattern": null,
        "commissionPercent": null,
        "buyLink": "...",
        "phoneBuyLink": false,
        "storeIdList": null,
        "storeNameList": null,
        "storeNumber": null,
        "cityIdList": null,
        "provinceIdList": null,
        "obsModelId": null,
        "desc": null,
        "shelfImmediately": null,
        "status": 1,
        "brandName": "...",
        "modelPreviewImg": null,
        "similarModelIdList": null,
        "similarModelImgList": null,
        "relatedModelId": null,
        "relatedModelImg": null,
        "brandAddress": null,
        "promotionActivityVO": null,
        "tagIds": null,
        "tagGroups": [],
        "favored": false
      &#125;
    ],
    "newsList": [
      &#123;
        "id": "...",
        "img": "...",
        "title": "...",
        "desc": "...",
        "date": null,
        "order": null
      &#125;
    ],
    "activityList": [],
    "itemListOrder": 1,
    "activityOrder": 4,
    "lessonOrder": 3,
    "newsOrder": 1,
    "designerOrder": 2,
    "comboListOrder": 2
  &#125;
&#125;

可以看到里面有非常多的字段,虽然一些公司会尝试使用类似 Yapi 等一些接口管理系统定义字段。

但随着业务发展,版本快速迭代,人员变动等因素影响,很有可能有一天

问前端人员,前端人员说这个是后端传过来就这样,我不清楚。

问后端人员,后端人员说这个是前端这么要的,我不清楚。

这上面的字段公司上下没有一个人能够完全描述清楚其作用。

这个时候如果该接口有业务变动,需要做字段调整,为了不产生未知的接口事故,很可能就说提出不改变之前的接口内容,新增一个接口字段实现功能的方案。

长此以往,接口返回越来越多,直到项目组花大力气,重写接口,前端重写接口对接。

先来看一段 Vmo 的代码:

import &#123; Vmo, Field &#125; from "@vmojs/base";

interface IFilterValue &#123;
  name: string;
  value: string;
&#125;
export default class FilterModel extends Vmo &#123;
  @Field
  public key: string;
  @Field
  public name: string;
  @Field
  public filters: IFilterValue[];

  public get firstFilter(): IFilterValue &#123;
    return this.filters[0];
  &#125;

  /**
   * 将数据适配\转换为模型字段
   * @param data
   */
  protected load(data: any): this &#123;
    data.filters = data.values;
    return super.load(data);
  &#125;
&#125;

const data = &#123;
  key: "styles",
  name: "风格",
  values: [
    &#123; name: "现代简约", value: "1" &#125;,
    &#123; name: "中式现代", value: "3" &#125;,
    &#123; name: "欧式豪华", value: "4" &#125;
  ]
&#125;;

const filterModel = new FilterModel(data); // Vmo通过load方法对数据做适配

通过以上方式就成功的将一组 json 数据实例化为一个FilterModel的数据模型。这将会为你带来什么好处呢?

  • 适配来源数据,处理需要改变的字段类型,如string => array
  • 可靠的字段定义,即使接口字段变动,数据模型字段也不会变
  • TypeScript书写提示,一路回车不用说了,爽
  • 计算属性,如firstFilter
  • 一次定义,终生受益。不认识\未使用的字段 say GoodBye
  • 如果项目需要迁移、后端同构,拿来即用。

在 Vmo 的设计中,数据模型只是基类,你同样可以为数据模型赋予一些 “特殊能力” ,比如数据获取

AxiosVmo 是基于 Vmo 派生的一个使用 axios 作为 Driver(驱动器) 实现数据获取、存储能力的简单子类。

你同样可以封装自己的 Driver ,通过相同接口,实现多态方法,来做到在不同介质上存储和获取数据。比如 IndexDB,LocalStorage。

import &#123; AxiosVmo &#125; from "@vmojs/axios";
import &#123; Field, mapValue &#125; from "@vmojs/base";
import &#123; USER_URL &#125; from "../constants/Urls";
import FilterModel from "./FilterModel";

// 商品查询参数
interface IGoodsQuery &#123;
  id: number;
  search?: string;
  filter?: any;
&#125;

interface IGoodsCollection &#123;
  goods: GoodsModel[];
  goodsRows: number;
  filters: FilterModel[];
&#125;

export default class GoodsModel extends AxiosVmo &#123;
  protected static requestUrl: string = USER_URL;

  @Field
  public id: number;
  @Field
  public catId: number;
  @Field
  public aliasName: string;
  @Field
  public uid: number;
  @Field
  public userId: number;
  @Field
  public size: &#123; x: number; y: number &#125;;

  /**
   * 返回GoodsModel 集合
   * @param query
   */
  public static async list(query: IGoodsQuery): Promise<GoodsModel[]> &#123;
    const &#123; items &#125; = await this.fetch(query);
    return items.map(item => new GoodsModel(item));
  &#125;

  /**
   * 返回GoodsModel 集合 及附属信息
   * @param query
   */
  public static async listWithDetail(
    query: IGoodsQuery
  ): Promise<IGoodsCollection> &#123;
    const &#123; items, allRows, aggr &#125; = await this.fetch(query);
    const goods = items.map(item => new GoodsModel(item));
    const filters = aggr.map(item => new FilterModel(item));
    return &#123; goods, goodsRows: allRows, filters &#125;;
  &#125;

  public static async fetch(query: IGoodsQuery): Promise<any> &#123;
    const result = await this.driver.get(this.requestUrl, query);
    return result;
  &#125;

  /**
   * 将请求的数据适配转换为Model
   * @param data
   */
  protected load(data: any): this &#123;
    data.catId = data.cat_id;
    data.aliasName = data.aliasname;
    data.userId = data.user_id;

    return super.load(data);
  &#125;
&#125;

(async () => &#123;
  // 通过静态方法创建 GoodsModel 集合
  const goods = await GoodsModel.listWithDetail(&#123; id: 1 &#125;);
&#125;)();

像上面这样的一个GoodsModel中,即定义了数据模型,又定义了接口地址、请求方式与适配方法。 在返回结果中会创建出GoodsModel的数据模型集合。

最终打印的结果:

与以往前端思维不同,我大费周章的折腾这么一套出来。到底与原来一些常用框架思维中的 action 完成一切到底有什么不同呢?

请大家思考一个问题,action 的定义到底是什么呢?

最初 Flux 设计中, action 的设计就是为了改变 Store 中的 state,来达到状态可控、流向明确的目的。

Redux 中的 action 甚至都是不支持异步操作的,后来有一些变相的方式实现异步 action,后来又有了Redux-thunkRedux-saga这类异步中间件实现。

所以,最开始 action 的设计初衷是为了管理 Store 中状态,后来因为需要,开发者们赋予了 action 异步调用接口并改变 Store 状态的能力。

所以很多项目中,看到 action 经常会类似这样的方法,getUsers()调用接口获取用户数据,addUser()添加用户,removeUser()删除用户。

那么哪个方法会有异步请求呢?哪个方法是直接操作 Store 而不会发生接口请求呢?

Vmo 希望能够提供一种设计思路,将数据模型、异步获取与页面状态 分开管理维护。

将数据获取、适配处理、关联处理等复杂的数据操作,交给Vmo

Vmo处理后的数据模型,交给 Store。作为最终的页面状态。

Vmo还可以配合Mobx使用,完成数据模型与数据响应结合使用。

import &#123; Vmo, Field &#125; from "@vmojs/base";
import &#123; observable &#125; from "mobx";

interface IFilterValue &#123;
  name: string;
  value: string;
&#125;
export default class FilterModel extends Vmo &#123;
  @Field
  @observable
  public key: string;
  @Field
  @observable
  public name: string;
  @Field
  @observable
  public filters: IFilterValue[];

  /**
   * 将数据适配\转换为模型字段
   * @param data
   */
  protected load(data: any): this &#123;
    data.filters = data.values;
    return super.load(data);
  &#125;
&#125;

Vmo 强调的是一种设计

通过Vmo希望能够帮助前端人员建立起对数据的重视,对数据模型的认知。对数据的操作处理交给Model,恢复Store对前端状态的设计初衷。

Vmo 是我的第一个个人开源项目,凝聚了我对目前大前端数据处理的思考沉淀,源码实现并不复杂,主要是想提供一种设计思路。

GitHub 中有完整的 Example,感兴趣的读者可以移步至项目地址查看。

项目地址

让各位观众老爷见笑了,欢迎指点讨论~

个人邮箱:`wyy.xb@qq.com`

个人微信:wangyinye (请注明来意及掘金)

Tumax-H5数据流向及解析

相信很多  同事在刚刚接触图满意项目时,对项目中的数据流向和数据解析过程都很模糊, 不清楚数据到底是如何从一份 JSON 数据变为我们使用的数据对象。

这篇文章将会为你  介绍图满意项目中,数据的解析还原及数据流向,为你揭开这一神秘面纱。

通过这篇文章,你将会明白数据是如何通过接口返回的 JSON 一步步变为我们可以拿来使用的 Model,同时我会介绍为什么需要这样处理,如果不这样处理带来的  弊端是什么。

 当阅读完文章后,需要对图满意项目有明确的数据流向认知,清晰  明了的了解 dataObject、ModelData 及 Model 的区别。后续开发业务代码需要明确  认知代码应该存放的位置。避免将代码放到不同阶段的类中,出现后期  不可维护、难以修改的情况。

首先,需要明确理解这张图,这张图中已经描述了我们对整个 Home 数据的解析、实现过程。我先来介绍一下图中出现的 3 种类型的类分别代表什么。

  1. 原始数据:JSON, 意思就是可被序列化的 JSON 数据,数据中用到需要保存、传输时的最终结果。
  2. Data 类:用于将 JSON 数据转为实体类的中间  类,所有对数据的解析、转换、适配都在这种类中处理。
  3. 实体类:在项目中真正用到的数据类型,也就是数据 Model。

我分别详细解释一下这 3 种类的典型事例及作用:

比如一份楼层数据:

{
  "id": "10",
  "name": null,
  "height": 2734.4058001657,
  "isMirror": false,
  "offset": {
    "x": 0,
    "y": 0
  },
  "pictureId": "20190115104131777061547519673942",
  "pictureType": 1,
  "totalClearArea": 29.01560021174,
  "uesGuideline": false,
  "useComstomLighting": true,
  "useIESLight": true,
  "compassRotation": 1.1699375695728,
  "areaIds": [],
  "switchIds": [],
  "scale": 1,
  "holes": [],
  "lightH5VIewIds": [],
  "ceilingIds": [],
  "bgWallIds": [],
  "parquetIds": [],
  "bkShapeTexturesIDs": [],
  "walls": [
      ...
   ],
  "curvedWalls": [
      ...
  ],
  "rooms": [
      ...
  ],
  "corners": [
      ...
  ],
  "pillars": [],
  "ceilings": [],
  "parquets": [],
  "bgWalls": [],
  "lightViews": [],
  "lightH5Views": [],
  "layoutGroups": [],
  "layoutModels": [],
  "holeTextureIDs": [],
  "floorTextureIDs": [
    ...
  ],
  "wallTextureIDs": [
    ...
  ],
  "pillarTextureIDs": [],
  "ceilingTextureIDs": [],
  "parquetTexturesID": []
}

在这份数据中可以看出,该数据可以被序列化来传输,并且在数据中会包含很多嵌套的基础数据,如 Wall,Corner 等。

如 ModelData,WallData,CornerData

在 Data 类中,会实现以下几个基础方法: 

  • build() 用于将实体类转为 Data类
  • rebuild() 用于将Data类转为实体类
  • extractToData() 用于将原始数据转为Data类
  • buildData() 用于将Data 类转为原始数据

Data 类主要会处理数据解析的各类内容对于一些父级的 Data 类,比如 HomeData,LevelData 可以调用其他子级 Data 类,用于协调解析其他基础内容。

Data 类中同样会处理一些适配内容,比如流程图中出现的 LevelAdapter。就是因为随着业务的不断发展,可能会更改原始数据的数据结构。原有的 Home 数据是一整体的,无法做到对单个楼层保存、序列化等处理。为了实现对单独楼层的序列化处理,我们需要将原有数据做一层处理,处理为所需要的原始数据,交给 LevelData 处理解析,避免 HomeData 直接解析 Level 中的数据。

类似的还有处理 Flash 的一些适配数据,如 CurvedAdapter、RectWindowAdapter 等,都是用于处理一些不好解释的数据。

需要注意的一点是:Adapter 的使用要遵循在  源头及末尾处理,切勿在解析过程中处理,比如 Flash 的 z、y 坐标轴与 H5 的坐标轴刚好相反,处理这类数据一定要在解析的源头处(还未变成 Data 类的时候处理),切勿在 Data 解析为实体类的时候再处理这类  数据。同样的,在输出时做数据适配还原也一定要在数据  已经解析成为原始数据后  再走适配逻辑,切勿直接修改 Data 类做  相关适配,后患无穷。

如 Home,Level, Model, Wall, Corner

真正在项目中使用最多的数据对象,通过操作数据对象可以同步修改  视图内容及  页面的属性面板(DOM)。

数据对象可以看做一个大的状态管理池,数据对象中存在大量关联关系,如墙体(Wall)中可能关联挂墙模型(Models),墙体(Wall)中可能关联墙角(Corners), 墙角(Corner)中可能关联多个墙(Walls)。

数据对象本身就是一个发布者。 遵循观察者模式,数据对象通过 Mobx 本身可以当做一个发布者,关键数据改变将会触发通知订阅这些关键数据的对象。比如 Model.position.x 改变,Model3D\Model2D 在实例化的时候会订阅这些关键数据。那么,position.x 改变将会通知到对应数据做到实时更新改变。

注意:

上述内容中,我们在方便的使用了各种关联、订阅后,也同样可能会留下很多“后遗症”。

比如 Wall 关联了 Corner,那么 Corner 在销毁的时候就需要一定处理  他与 Wall 之间的关联,否则 JS 的垃圾回收机制并不会将这部分内容销毁。

再比如 Model2D 中订阅了很多 Model 关键数据的内容,那么在 Model 的发布池(_events)中就会存在 Model2D 的引用,如果 Model2D 在销毁的时候没有  处理释放这些订阅,他们之间的关联就会一直存在。那么如果来回切换几次楼层,这种后果不可想象。

所以在使用到 Mobx,EventEmit(on,emit),相互关联时,一定要意识到销毁的时候如何处理,多想一步。

上述内容中会吧所有的订阅返回丢入this._disposeArr中,这样在基类的 destroy 中,就会处理引用关系了。

我们上面的流程图是按照整个户型数据的维度来看,但是在很多时候,我们可能需要的是将某个基础实体类(Model,Group,Hole)单独处理为序列化的原始数据,或者从 SideBar 中的原始数据单独处理为实体类。

遇到这类情况需要思考的第一思路就是  借助 Data 类,保证 Data 类的四个基础方法是实现并且可以用的,那么整体数据就已经可以实现。

如果发现实现过程中出现数据不统一,户型数据中的解析字段与其他过程的解析字段不同, 尽量借助适配器(Adapter)转换解析字段,保证 Data 类解析过程尽量统一。这样做的好处是后续如果对解析过程中有修改,或者  增加字段等能够做到错误最小, 耦合性最低。

切勿单独再写一套解析方法,这样很容易导致后续一个  实体类的数据变动引起解析保存失败等严重事故。

在不断的迭代中,可能会出现需要对某个实体类的原始数据做字段增加,此时应该如何处理这类需求。

其实在了解了整体的  数据解析流向后,这类需求自然应该迎刃而解。

首先应当在对于的 Data 类中建立对应解析字段,然后分别实现 4 个基础方法动作中对数据的处理步骤。在最终的序列化数据中就已经出现了。

首先需要确定是,这份数据是否是完整的。完整的意思就是, 关键性数据还是存在的,只是数据  保存的格式与原来数据不适配。

若非完整数据,则根据metaData.compatibilityVersion最低向下兼容版本,提示数据不兼容异常 。

若完整数据,那么就需要做适配器处理。但有些数据是不需要做保存适配的,也就是数据这样解析后,不需要在保存的时候对数据做原始转换(比如:弧形墙),这类适配就只需要做单向适配。而有些数据的适配是需要在保存的时候依然转换回之前的数据格式,这就需要分别实现 in、out 两种方式

  • 对 Data 类实现基础接口IDataExtract

Tumax-H5设计要点

使用 Vuex 更贴合 Vue 项目,Vuex 支持响应 Vue 内部的响应事件,比如:当某状态改变时 Vue 会同时触发改变 computed,render 等。

但缺点是在其他库中缺无法观察响应 Vuex 中的变化,比如:当 Vuex 中的一个状态值改变,需要对应的触发响应到 ThreeJS 和 PIXI 中去改变摄像机的位置,则需要在某个在生命周期范围内的组件去处理响应事件,并通知到 ThreeJS 和 PIXI 中去改变响应数据。这样整个过程就变得十分麻烦。

另外,当 ThreeJS 中有任何数据改变,反过来如果要触发改变 Vue 的 Dom 和 PIXI 中的绘图,就又需要在 ThreeJS 中去触发改变双方的事件。这样的做法不符合 Flux 的设计思想,整个过程也变得十分累赘和繁琐。

最理想实现方式应该是:有一个公共的数据状态在管理整个数据层,比如相机的位置由他的向量 Vector3 决定,而其中包含的 x,y,z 这 3 个分量是他的数据层中最重要的信息,还有相机的旋转同样由 Euler 中的 x,y,z 决定。

那么在项目设计中,就需要做到当相机的 x,y,z 的任意一个值改变时能同时通知到 Vue、ThreeJS 与 PIXI。这样处理后,在编写代码时,就只需要编写订阅状态变化后各自层的实现内容。比如 ThreeJS 订阅到相机位置变化,就需要修改自身的相机配置;PIXI 订阅到相机变化需要转化成对应的俯视图重新绘制 2D 层的显示;Vue 订阅到相机变化需要做单位转换后呈现数据到 Dom 结构中进行数据双向绑定。

反过来,当任意一个层去触发改变时,出版者会重新发布新事件到所有订阅者中去触发新一轮的改变动作。这样 Vue 中一个 Slider 的双向绑定就可以同时触发改变到 3D 和 2D 层。

虽然在我们的设计中,这种思路很完美,但如果要抽象出这样一个出版者,其中需要用到的内容细节却很多。比如 Vue,ThreeJS,PIXI 是在什么时机去订阅到出版者,如果在每个使用到出版者发布的变量位置都需要使用 addEventListener 添加,那么在使用起来其实并不轻松而且需要改很多地方。

在 Vue 组件中,也不能使用 Vue 内部已经实现的响应机制,而是需要在观察到变化后手动更新视图,那这样 Vue 内部实现的虚拟 Dom,计算变量缓存这些性能优化也都不会使用。同样的类似 v-model 这样的语法糖也同样会不能使用。这种情况肯定不是我们最终想要的结果。

对于这样类似的问题,最终决定引入 Mobx 来解决相关问题,首先 Mobx 有较为完善的文档和方法封装,在定义一个出版者变量时使用装饰器就变得非常轻松,另外 Mobx 内部有借鉴 Vue 的响应原理,在使用起来也十分相似。

这样以来,我们需要做一个可观察的 Vector3 就变得非常轻松!

ThreeJS 中的响应代码实现:

由于 Mobx 官方支持的是 React,所以在我们一般说 Mobx 还是属于 React 技术栈,那么在响应 Dom 这件事情上就需要我们做一些额外处理来达到 Vue 组件中可用 Mobx。在看过 Mobx 源码后了解到,其实 Mobx 内部实现响应机制很像 Vue,如果把 Mobx 变量看做一个 Vue 中的 computed,那么我们的使用过程就变得非常舒服了。

而这其中就需要开发一个 Vue 的兼容 Mobx 插件,在一个 Vue 组件 create 创建时将定义的 FromMobx 中的计算方法转换成为 computed 的加入到 Vue 组件的 computed 中,这样就可以将 Mobx 简单的接入到 Vue 项目中。

在项目中全面接入 Mobx 管理状态后,在很多地方实现相关功能都变得非常轻松,在相机的位置改变,旋转时都只需要改变对应的数据层,即可完成整个操作,实现思路变得非常清晰,各自层级处理的事件互不干扰。

在项目处理新增模型的实践时,需要对监听 mouse 的 start,move,end 等事件,随着项目的不断扩展和代码量的增加,渐渐发现有非常多的交互需要使用到鼠标的的相关事件,我们在开发过程中进场会遇到在调试过程中跑了别人写的 mouse 事件,甚至还会遇到在别人的代码中直接 event.preventDefault()这类停止冒泡的命令。

还有,在考虑到移动端的适配 Touch 的 start,move,end 也同样需要做事件处理,这就使得我们的项目中对于 Canvas 的监听事件非常混乱。编写 ThreeJS 交互相关代码时非常难受,因为编写每一行代码都可能出现不运行,或者和别的交互事件同时运行。比如:当鼠标在 3D 场景中拖动时,要的效果是让场景中的物体移动,此时环绕控制器(控制相机在场景中围绕某个中心点旋转)也会触发。

所以在处理 3D 场景的交互时,需要抽出一个模块出来,用于专门管理场景中会出现的一些交互问题。也就是说需要抽出一个 Manager 解耦各类 Event,比如旋转,移动,抬高,添加材质,键盘事件,组合等等。

首先封装了 DOMEventManager 来监听所有页面中能用到的交互事件,包含了窗口 resize,在 ThreeJS 画布中的 mouse、touch 事件,在 Window 中的 mouse、touch 事件(主要用于处理一些在 Canvas 之外的处理事件,比如从左侧的 sideBar 中拖动模型进入场景)。

兼容了 mouse 和 touch 事件的主要数据结构,达到使用同样的冒泡事件处理 touch 和 mouse 事件来快速完成对交互的适配工作。

另外引入 Hammer,来快速兼容一些特殊的交互事件(pan\pinch),比如当用户在场景中在模型的位置按下鼠标,如果接下来他做了横向平移,那么接下来的事件会交给摄像机控制器去处理摄像机的移动;如果接下来他在 200 毫秒以内抬起鼠标,并且鼠标的位置在之前按下鼠标的一定范围内,就认为他在点击模型,那么接下来的事件就会交给模型控制器去处理。

首先在 3D 中建立 Events,这其中编写用于处理不同事件的具体实现方法,比如添加材质,组合\解组,调高模型,移动模型,旋转模型,切换模型状态等等。

在 Base 中封装了对整个事件的总体开关,当开关开启时,相关事件开始监听并处理各自事件。反之,当该事件开关关闭时,remove 监听事件来避免事件紊乱。

在完成了对 Dom 的事件兼容封装和 Event 事件管理封装后,就需要编写各自事件所处理的具体内容,以及定义在什么情况下该 Event 开关会开启,又在什么时候进行关闭。拿模型旋转来举例,只需要借助 Mobx 的方法监听对应状态,即可完成对整个事件的监听操作。而当开关关闭时,dispose 函数会被触发来关闭整个过程中的监听事件!

在 3D 场景中需要有一个控制器的东西来操作已经在场景中的物体模型,虽然只是对物体进行简单的移动、旋转、调高操作,但其中涉及到的细节非常多。

比如在模型控制器距离摄像机的位置较远时,如果不对模型控制器进行处理将会导致模型控制器过小而无法操作。

比如在操作过程中,任何操作都可能会导致鼠标的射线不是与物体直接相交的,比如正在调旋转的过程中,一开始鼠标是和旋转箭头想模型相交的,随着鼠标移动,按照用户习惯鼠标将不再与旋转箭头相交,而是与整个地面相交。

比如在调整高度之后,如何在物体移动或旋转过程中,相交地面还是之前的高度,则会导致物体重新被调至地面高度位置,也就是 0。

比如在任何 3D 交互操作中,如果直接按照相交点来设定实际参数将会出现瞬移情况。比如一个物体较大时,中心点位置在物体的底部中央,在点击物体的左上角位置开始移动时,物体相交点将会是左上角点到地面的射线位置,那么中心点将会在一开始就直接移动到相交点的位置。

比如在摄像机控制器移动时,调高箭头需要一直面向摄像机 在调高箭头 所在的平面内 的投影方向。

在处理这么多细节的问题过程中,如果不做好解耦工作,那么整合模型控制器的代码量将会非常庞大,也非常难以阅读并且 Bug 数量一定不会少,毕竟你永远不知道你的用户是如何使用你开发的产品。

为了解耦不同的情况下需要处理的事件,我们在全局封装了 ModelActiveData 的单例用于管理整个场景中正在操作的模型,包含了正在编辑的模型,拖动的模型,旋转的模型,调高的模型,移动的材质,以及摄像头是否在移动等状态。

有了这些可观察的 Mobx 状态,就可以在项目的任意位置来根据他们的不同事件作出响应,比如在摄像机控制器中,就可以监听如果有任意的 editingData 中的数据不为空,就停止摄像机控制器的所有操作。

比如在监听到在 data 中的 Editing 不为空时就显示模型控制器以及左侧的属性面板。并且显示的数据就是在 editing 中设置的模型数据。

完成了上面的 Mobx 状态封装,在 Events 中就可以监听 Editing 的数据变化,当有物体模型在进入编辑状态时,需要对鼠标在 Canvas 中的 mousedown、touchstart 等事件做出监听,通过不同的点击事件,判断进行不同的事件委派。

在收到事件委派后相关的具体业务事件方法将会处理对应的相关逻辑了,这里拿相对简单的物体旋转来具体分析一下。

首先在开始触发旋转时为了防止出现像前面说的那种瞬移的问题,必须要拿到起始点的相交点位置,和起始时的旋转角。下面有一张草图来理解旋转的选择位置和实际位置。

此处的位置都是相对位置坐标,不是绝对位置坐标,坐标原点都是模型中心点,也就是说坐标原点就是旋转圆环的圆形点。首选需要计算出点击的位置的相对位置点,然后计算结果时也计算中相对位置点,计算两向量之间的顺时针偏移角,和起始旋转角相加。此时可能会因为相加得到大于 2π 的弧度,再将弧度归一化。最后,因为一个圆环是有 8 个刻度线,所以旋转角再做一个吸附转换,即可得到最终需要的旋转角。

由于模型和组合在抽象的看待他们是相同的,对模型组合的操作同样可以理解为对单个模型的操作,他同样有自身的 Position、Rotation、Width、Height、Length 这些数据。

那么在编写 Group3D 时,就考虑了将 Group3D 和 Model3D 共同继承与 ModelBase。同样的在数据层,也封装了对应的 Model 和 GroupModel 来处理数据的改变。前面说过我们的所有 3D、2D 层的具体改变都来自于数据层,所以在 Model 和 GroupModel 中都定义了可观察的成员变量。

按照常规方式,生成一个 Mesh 的 BoundingBox 需要在 Mesh 完全载入后找到该 Mesh 的 Geometry 后遍历其所有的 Vectors 然后找到 8 个最大、最小的 Vector 点来确定然后生成。

这样的方式是传统的实现方式,其中有三个缺点:

  • 生成边框必须要等 Mesh 完全 loaded 模型数据。在模型组合时,如果该模型还未完全载入时,则无法计算出正确的 BoundingBox。
  • 计算量较大,一个普通的模型一般会有将近 1w 个三角面组成,一个三角面有 3 个 Vector 点。所以当计算一个新模型的边框,或者更新一个组合的新边框时将会遍历计算n(模型数量)*faces(面数)*3次。
  • 完全依赖于 ThreeJS,如果从 2D 层操作并计算其外层边框时,在 2D 逻辑中又需要 2D 的方式实现一遍。

针对分析这 3 点问题后,决定从 Model 数据层出发,自己开发一套生成边框数值的方法。

因为在提供的数据接口中已经存在对应模型的长宽高等数据,通过计算,是可以明确得到对应 8 个点的相对坐标的。其中存在的问题便是如何从纯数据的方式将相对坐标转化成世界坐标。

可能你会觉得很简单,模型不是在数据层也有他的 position 吗,一个向量加减不就搞定了吗?但是如果加入旋转呢,比如该物体在 Y 轴方向旋转了 π/4,那么他的位置就会变成以下情况。那如果在加入 X 轴方向和 Z 轴方向的旋转呢?

在研究过 ThreeJS 的 Object3D 的源码后,使用 Matrix4(4x4 矩阵)完成了对物体顶点的相对坐标转换,主要是可以使用 Vector3 应用一个世界的 Matrix4 来完成对 Vector3 的计算,生成 8 个世界坐标顶点。

拿到 8 个世界坐标顶点后,可以再通过 ThreeJS 中提供的 Box 方式来快速扩展生成 Box 盒子,通过 ThreeJS 中的 Box 提供的方法,可以得到对应的中心点及 BoxSize 来生成对应 Group 的 BoundingBox 和 CenterPoint。

从数据层拿到 Box 实例后,再去 3D 层或者 2D 层建立 BoundingBox 就变得非常简单了!

JavaScript面向对象和模块化

在一次面试过程中,一位已经有 5 年工作经验的前端,在回答面试问题时这样说到。

问:你能说说 JS 的面向对象和设计模式吗?
回答说:这些内容主要是后端的 Java,C#这种高级语言才会用到的,前端一般我们没有用到。

对于这样的回答,不禁让我有点无话可说,JS 中是否有必要使用面向对象以及设计模式呢?我列举了以下几个场景:

一般的,在请求接口方面,我们一般会使用一些第三方库,比如axios。然后在逻辑代码部分,比如在组件中直接使用axios进行请求,例如:

let methods = &#123;
  /**
   * 获得分类信息
   */
  async getBarData() &#123;
    try &#123;
      let res = await axios.get(url, params);
    &#125; catch (e) &#123;
      console.error("something error", e);
    &#125;
  &#125;
&#125;;

这样的做法在功能上讲没什么问题,但在新增一些其他动作后,这样的做法就变得非常难以管理。比如,需要在请求中加入一些关联请求,需要获取一个商品页的列表,查询参数包含,分页参数(当前页,查询数),分类 Id,搜索内容,排序方式,筛选项。执行该去请求时,发现分类 Id 也需要另外一个接口去获取。于是代码成了:

let params2 = &#123;
  sort: -1,
  search: "",
  filter: "",
  page: &#123;
    start: 1,
    number: 10
  &#125;
&#125;;
let methods = &#123;
  /**
   * 获得商品列表
   */
  async getGoodsData() &#123;
    try &#123;
      let &#123; id: typeId &#125; = await axios.get(url.goodsType, params1); // 获取所有分类Id
      let res = await axios.get(url.goods, &#123; ...params2, typeId &#125;); // 获取商品
    &#125; catch (e) &#123;
      console.error("something error", e);
    &#125;
  &#125;
&#125;;

上面的代码中,我们简单的实现了获取一个接口的值然后请求另外一个接口。那么如果当前的搜索内容、或者分页数据修改了,还需要重新获取新的商品数据,此时getGoodsData还需要执行一遍,而获取分类的请求又需要请求一遍,所以需要改动代码为:

let params = &#123;
  sort: -1,
  search: "",
  filter: "",
  page: &#123;
    start: 1,
    number: 10
  &#125;
&#125;;
let methods = &#123;
  /**
   * 获得分类信息
   */
  async getTypeData() &#123;
    try &#123;
      let &#123; ids &#125; = await axios.get(url.goodsType, params1); // 获取所有分类Id
      this.typeIdNow = ids[0];
    &#125; catch (e) &#123;
      console.error("something error", e);
      throw e;
    &#125;
  &#125;,
  /**
   * 获得商品列表
   */
  async getGoodsData() &#123;
    try &#123;
      this.typeIdNow === undefined && (await getTypeData());
      let typeId = this.typeIdNow;
      let res = await axios.get(url.goods, &#123; ...params, typeId &#125;); // 获取商品
    &#125; catch (e) &#123;
      console.error("something error", e);
    &#125;
  &#125;
&#125;;

params的任意数据改变后会请求getGoodsData,这样暂时我们已经实现了一个商品请求的逻辑,并且支持数据暂存。

紧接着问题又来了,切换类别时会要求获取新的筛选列表(不同的分类下筛选列表是不同的)。

切换类别后,会要求重置params,因为之前的搜索值,分页值在切换类别后不能继续使用。

字段组装,比如筛选字段的filter,一般的后台可能会用一些特殊的分隔符比如(|)来做多个筛选项的分割,此时我们又需要处理以下的代码:

return this.types.map(val => val.id).join("|");

节流优化,用户输入 Value 时,需要做防抖函数的优化,防止一直请求接口。

恩,终于,当一大堆问题都解决后,需求来了,能不能在其他组件使用这些数据?? 哇特?

回顾一下,我们要做的就是一个内容,getGoodsData为什么会同时出现这么多代码呢?一个商品列表的组件会需要这么多组装数据的代码吗?

面对这种让人抓耳挠腮,看着头晕的代码难道就没有更优雅的实现方式吗?面向对象了解一下,数据模型了解一下!

我们可以将Goods这一中数据类型抽象成为一种资源对象,在 Model 中专门处理Goods获取时所需要的数据组装等工作。

import &#123; API, axios &#125; from "../api";

/**
 * 商品列表数据
 */
class Goods &#123;
  private params: Object = &#123;&#125;;
  private initParamsData: Object = &#123;
    sort: -1,
    search: "",
    filter: "",
    page: &#123;
      start: 1,
      number: 10
    &#125;
  &#125;;

  constructor() &#123;
    this.initParams();
  &#125;

  /**
   * 初始化所有请求参数
   */
  public initParams() &#123;
    this.params = JSON.parse(JSON.stringify(this.initParamsData)); // 深拷贝
  &#125;

  /**
   * 设置请求参数
   * @param key
   * @param val
   */
  public setParams(key, val) &#123;
    this.params[key] = val;
  &#125;
  /**
   * 获取商品请求
   */
  public async get(params = &#123;&#125;) &#123;
    let &#123; id: typeId &#125; = await Type.get(); // 在另外一个Type类中获取并做缓存处理
    params = &#123; ...this.params, ...params, typeId &#125;;

    let res = await axios.get(API.GOODS_LIST, &#123; params &#125;);

    return res;
  &#125;

  public async save() &#123;&#125;
&#125;

export default new Goods();

然后就可以在组件中优雅的进行使用,Goods的数据模型中已经可以自行处理依赖请求,缓存数据,参数组装等功能,在另外组件使用中也同样可以使用相同的数据和缓存,代码如下:

let methods = &#123;
  /**
   * 获得商品列表
   */
  async getGoodsData() &#123;
    try &#123;
      let res = await Goods.get(); // 获取商品
    &#125; catch (e) &#123;
      console.error("something error", e);
    &#125;
  &#125;,
  /**
   * 设置请求参数
   * @param key
   * @param val
   */
  setParams(key, val) &#123;
    // 设置请求参数,对于部分需要特殊组装的字段可以在类中单独分装方法处理
    Goods.setParams(key, val);
  &#125;
&#125;;

一般的,在处理多组件数据通信时,会使用Redux/Mobx/Vuex这类Flux模式的状态管理库来处理。对于Vuex来讲,一般会在实例化一个Vuex,设置其state对象以及对应的mutations,然后将其挂载到 Vue 的原型链中,就可以方便的完成响应式的状态管理。

实际上,当项目中的不同路由层级,不同组件,不同生命周期的状态都混在一个state中管理是很混乱,很不明智的做法。当然 Vuex 中也提供了Modoles的用法,让我们可以通过不同的模块化来管理不同状态,从某种程度上来讲这也是一种面向对象的做法。

const moduleA = &#123;
  state: &#123; ... &#125;,
  mutations: &#123; ... &#125;,
  actions: &#123; ... &#125;,
  getters: &#123; ... &#125;
&#125;

const moduleB = &#123;
  state: &#123; ... &#125;,
  mutations: &#123; ... &#125;,
  actions: &#123; ... &#125;
&#125;

const store = new Vuex.Store(&#123;
  modules: &#123;
    a: moduleA,
    b: moduleB
  &#125;
&#125;)

store.state.a // -> `moduleA`'s state
store.state.b // -> `moduleB`'s state

但相对于Mobx的 Class 式用法,这种VuexModule的用法还是显得有些麻烦,让我们来看看Mobx的 Class 定义状态管理是如何处理的:

import &#123; computed, observable &#125; from "mobx";

class StoreData &#123;
  @observable isSideBarShow = true; // 是否显示侧边栏
  @observable routeNow: any = &#123;&#125;;

  public setSideBarShow(val) &#123;
    this.isSideBarShow = val;
  &#125;

  public setRoute(val) &#123;
    this.routeNow = val;
  &#125;
&#125;

export default new StoreData();

相比与Mobx的这种状态管理,引入了 Es7 中的装饰器,在编写代码中如果有使用其中所需要的状态值,可以直接方便的引入StoreData,然后直接使用即可。

从定义、使用、修改各种步骤都更容易理解和使用。

很遗憾的是,Mobx目前还没有完善的方案在 Vue 中使用。

Vuex社区中,也有很多开发者有着同样的感受,所以出现了类似Vuex-class这样的库,来使用Class模式编写状态管理。也希望Vuex官方能对面向对象编程有更好的支持!

组件可以继承吗?比如我写了一个投票页面,用户是可以操作进行投票的,但是当他投票结束后,这个页面就不能再进行交互操作了,用户只能查看已经投票的结果。

正常的,我们可能会使用一个状态值来判断是否开放编辑功能。但是,当投票活动再加入:开始前,正在投票,投票结束等状态后。需要处理的就是:开始前已经投票的状态,开始前未投票的状态,正在投票已经投票的状态….这样 6 种状态。虽然从其他维度可以解决这样的问题,但状态再多复杂度就会再增加一层。如果还这样写,那么,恭喜你,传说中的面条代码就产生了!

所以我们最好还是用两个组件来做这件事情,一个组件用于提交,一个组件用于查看。在完成提交组件后,再实现查看组件时,会发现非常多的代码都是重复的 ,就比如投票数据获取,样式,模板,状态管理。

此时,组件继承的需求就变得愈发强烈,实际上,组件继承是可以实现的。拿 Vue 来说,在 Vue2.5 后 Vue 推出了vue-class-component。看看以下组件 Test1。








继承组件 Test2,在继承 Test1 后就可以使用 Test1 中定义的变量、样式,对于模板,实际上 Vue 在 template 中的内容最终也会被转移为render函数中返回的模板值,如果你的 JSX 了解的话,你可以把它理解为 JSX,只不过 Vue 把它换了一个位置,如果你想在 Vue 中使用 JSX 同样是可以实现的,参考文档。那么 Test1 就可以写成:



说了这么多,那么在 JavaScript 中是如何实现Class的呢?在 ES5 的标准中,是并没有Class关键字的。

在 JavaScript 中的所有数据跟对象都是 Object.prototype 对象。我们在 JavaScript 遇到的每个对象,实际上都是从 Object.prototype 对象克隆而来的,Object.prototype 就是他们的原型。

在 JavaScript 中执行的new Object(),在内部引擎中实际上是从 Object.prototype 上面克隆一个对象出来,我们最终得到的才是这个对象。

function Person(gender) &#123;
  this.gender = gender;
&#125;
Person.prototype.getGender = function() &#123;
  return this.gender;
&#125;;

var me = new Person(1);
console.log(me.getGender());

在 JavaScript 中没有类的概念,但上面这段代码中不是明明调用了new Person()吗?

在这段代码中,Person并不是一个类,而是函数构造器。当使用new来调用函数时,实际上是在克隆Object.prototype对象,然后再才开始运行函数。

所以当我们得到me时,内部已经完成了对Person.protorype的克隆,当请求me.getGender时,JavaScript 完成了以下几步操作:

  • 尝试查找me对象中是否有getGender属性
  • 没找到genGender,把该请求委托给Person的构造器原型,原型会被记录在__proto__中。
  • 在原型链中找到了getGender属性,并返回它的值

多态的实际含义是:同一操作作用于不同对象上面,可以产生不同的解释和不同的执行结果。多态分为编译期多态、运行期多态。

举个例子:两个人打招呼,不同的人见面打招呼的方式不一样。比如好基友见面,会说:Hey,老哥~。陌生人见面会说:你好,幸会。Github 上提问会说:Hello,nice to …。韩国人见面打招呼会用韩语,日本人见面打招呼用日语等等。

用一段 JavaScript 代码来实现英国人和中国人打招呼:

var Chinese = function() &#123;&#125;;
var British = function() &#123;&#125;;

var sayHello = function(man) &#123;
  man instanceof Chinese && console.log("你好");
  man instanceof British && console.log("Hello");
&#125;;

sayHello(new Chinese()); // 编译期多态
sayHello(new British());

这段代码确实实现了”多态性”,当在发出sayHello的命令后,不同的人会执行不同的打招呼方式,但却不是理想化的。试想如果后来新增了一个俄罗斯人,就必须要修改sayHello函数,才能实现俄罗斯人打招呼。那么后面再加入对不同人打招呼,再增加其他国家的人,sayHello函数将会变得非常庞大和难以维护。

从源头来看sayHello这个动作中要输出什么的逻辑是由不同类型的人定义的,所以应该将sayHello封装起来,作为不同类型的人sayHello的一种方法。这就属于一种面向对象,代码变成了一种可扩展,可生长的代码。修改,并加入俄罗斯人的代码:

var Chinese = function() &#123;&#125;;
Chinese.prototype.sayHello = function() &#123;
  console.log("你好");
&#125;;
var British = function() &#123;&#125;;
British.prototype.sayHello = function() &#123;
  console.log("Hello");
&#125;;
var Russian = function() &#123;&#125;;
Russian.prototype.sayHello = function() &#123;
  console.log("#&(*$(K");
&#125;;

var sayHello = function(man) &#123;
  man.sayHello(); // 运行期多态
&#125;;

sayHello(new Chinese()); // 编译期多态
sayHello(new British());
sayHello(new Russian());

在实现多态的同时,JavaScript 中同样可以使用继承来实现类的多样性。比如我和 MilkGao 都是中国人,我们一般遇到其他人会说”你好”来打招呼,但是我们俩见面后因为一些其他的原因,打招呼的方式会不一样。我见到他会说:Hey,老哥。他见到我会说:哇,帅哥!

分析这段逻辑,两个人都是在跟特定的人打招呼(做同样的动作),两个人都是中国人,遇到陌生人都会说”你好”,来打招呼。两个人不同的地方是,相互见面后打招呼的内容不同。所以可以都继承中国人来处理相同的打招呼逻辑,又有各自不同的遇到朋友的打招呼方法。

var Chinese = function() &#123;&#125;;
Chinese.prototype.sayHello = function() &#123;
  console.log("你好");
&#125;;

var YeeWang = function() &#123;
  Chinese.call(this);
&#125;;
YeeWang.prototype = Object.create(Chinese.prototype);
YeeWang.prototype.constructor = YeeWang;
YeeWang.prototype.sayHelloTo = function(man) &#123;
  if (man instanceof MilkGao) console.log("Hey,老哥!");
  else this.sayHello();
&#125;;

var MilkGao = function() &#123;
  Chinese.call(this);
&#125;;
MilkGao.prototype = Object.create(Chinese.prototype);
MilkGao.prototype.constructor = MilkGao;
MilkGao.prototype.sayHelloTo = function(man) &#123;
  if (man instanceof YeeWang) console.log("哇,帅哥!");
  else this.sayHello();
&#125;;

var twoPersonSayHello = function(man1, man2) &#123;
  man1.sayHelloTo(man2); // 运行期多态
&#125;;

twoPersonSayHello(new YeeWang(), new MilkGao()); // 编译期多态
twoPersonSayHello(new MilkGao(), new YeeWang());

既然说 JavaScript 的面向对象,就不能不提TypeScript
关于 TypeScript 的文档我就不具体介绍了,如果官网有详细的 TypeScript 的使用、规范说明。我列出了几点关于 TypeScript 相对于 JavaScript 的优势点和注意事项。

  • 首先 TypeScript 编码过程中要求对变量进行类型定义,比如在项目中一旦定义一个变量的类型后,如果赋值类型不同,在编译器中就会直接报错,这或许在你看来比起 JavaScript 这显得非常麻烦,但对于长期受益来讲这会显得非常有用。

  • 自动提示,使用 TypeScript 定义好Class后,在使用过程中,都会有对这个类的自动提示,在编码过程中一路回车,体验真的不要太好!相比于之前使用 JS 时借助 IDE 一些插件实现的关键字自动检索,TypeScript 的提示速度更快更准确!

  • 参数提示,在使用 TypeScript 编码时,如果遇到陌生的方法,可以直接快速追溯到该方法的定义,迅速查找参数类型。比如在使用lodash中的方法函数,就可以快速查到 findIndex 中所需要到参数类型,以及返回类型。

  • 定义文件(.d.ts),使用 TypeScript 一定要注意的一点是,如果引入非 TypeScript 写的库。发现 import 报错,那么很有可能该库没有更新配置 TypeScript,目前大多数用到的库都已经有对 TypeScript 的支持包括Vue,React,Lodash等等,但还是有一些库官方并没有更新.d.ts的类型定义文件,对于这类文件TypeScript另外做了一个开源项目,专门整理各大库的定义文件。比如three这个库,如果要使用 TypeScript,只需要运行npm i @types/three -D就可以匹配找到该库的类型定义文件啦。

一套优秀的系统源码,是文件多、还是文件大?

对于上面这个问题答案是肯定的,一套优秀的系统源码应该是尽可能将逻辑颗粒度细化,尽可能的抽象和模块化可以使业务代码变得相对较少。

究竟什么是模块化?其实在 Vue/React 中的组件,就属于模块化,每个组件都被抽象成为一个 module 暴露出来,在其他组件中被使用,并被框架按照自己的组件处理方式制作成最终业务效果。

以下代码都是在对外暴露一个模块。

export default &#123;&#125;; // ES6
module.exports = &#123;&#125;;
  • 静态加载:在编译阶段进行,把所有需要的依赖打包到一个文件中
  • 动态加载:在运行时加载依赖

AMD 标准是动态加载的代表,而 CommonJS 是静态加载的代表。
AMD 的目的是用在浏览器上,所以是异步加载的。
而 NodeJS 是运行在服务器上的,同步加载的方式显然更容易被人接收,所以使用了 CommonJS。

import Gallery from "@/views/Gallery"; // 静态加载
const Gallery = () => import("@/views/Gallery"); // 动态加载

为什么要使用组件呢?

在很早以前(我还在做 PHP 的时候)和朋友在谈起 Laravel 框架时说到:恩,我觉得这个框架很强大,很多代码都是一个方法里面嵌套了很多其他方法,代码阅读起来非常舒服。朋友:我最讨厌这样的写法,一层嵌一层都不知道他在干什么。我:…

为什么要使用模块化?为了尽可能的少写代码。

使用模块化可以让我们在编写代码时,会”少写”很多代码。
我们在实现业务逻辑时可以尽可能的对代码复用,从而减少很多可能会出错的几率,增加开发效率和可维护性。

// 常量
export const HOST = "127.0.0.1";
export const HELLO_MSG = "你好";

// 方法
export function wait(time) &#123;
  return new Promise(resolve => &#123;
    setTimeout(resolve, time);
  &#125;);
&#125;

// 类
export default class Vector2 &#123;
  x = null;
  y = null;
  add() &#123;&#125;
  sub() &#123;&#125;
  distence() &#123;&#125;
&#125;

Unified Modeling Language (UML)又称统一建模语言或标准建模语言,是始于 1997 年一个 OMG 标准,它是一个支持模型化和软件系统开发的图形化语言,为软件开发的所有阶段提供模型化和可视化支持,包括由需求分析到规格,到构造和配置。 面向对象的分析与设计(OOA&D,OOAD)方法的发展在 80 年代末至 90 年代中出现了一个高潮,UML 是这个高潮的产物。它不仅统一了 Booch、Rumbaugh 和 Jacobson 的表示方法,而且对其作了进一步的发展,并最终统一为大众所接受的标准建模语言。

UML 实际上在前期设计项目数据模型时是非常有用的一套工具,个人认为在构造一个关联级超过 3 层以上的功能时,都应该针对这个功能抽象制作 UML 图,这样非常有利于后面的代码编写。正所谓,磨刀不费砍柴工。

指的是一个类继承另外一个类的功能,并可以增加自己的新功能的能力,继承是类与类或接口与接口之间最常见的关系。

指的是一个 class 类实现 interface 接口(可以是多个)的功能;实现是类与接口之间最常见的关系。

可以简单的理解,就是一个类 A 使用到了另一个类 B,而这种使用关系是具有偶然性的、临时性的、非常弱的,但 B 类的变化会影响到 A;比如某人要过河,需要借用一条船,此时人与船的关系就是依赖。

他体现的是两个类,或者类与接口之间语义级别的一种强依赖关系,比如我和我的朋友,双方关系是平等的。

聚合是关联关系的一种特例,他体现的是整体与部分、拥有的关系,即 has-a 的关系,此时整体与部分之间是可分离的,他们可以具体各自的什么周期,部分可以属于多个整体对象,也可以为多个整体对象共享;

组合也是关联关系的一种特征,他体现的是一种 contains-a 的关系,这种关系比聚合更强,也称强聚合;他同样体现整体与部分间的关系,但此时整体与部分是不可分的,整体的生命周期结束也就意味着部分的生命周期结束;

说了这么多,主要是简单介绍一下 UML 最简单的一些类图关系定义。这个在画 UML 图、看 UML 图时都非常有用!如果不了解上面的这些箭头的含义,那么是很难理解 UML 类图的。

我举个例子,比如现在需要构建一个房子的全部数据。

一个房子需要些什么抽象模型?楼层,房间,墙,家具,吊顶,地板,踢脚线,窗口,门,墙角等等。

只看户型信息的话有哪些内容?楼层,房间,墙,墙中门、窗所需要的洞,墙面,吊顶,地板,每层的高度,地面、吊顶、墙面所需要的铺贴材质,材质铺贴的方向,墙的长、厚,墙洞的长宽。

家具这些需要什么内容?普通家具的长宽高、位置坐标和旋转方向,组合家具的长宽高、位置坐标和旋转方向。

面对这么多复杂的数据内容,我们必须要细化到每个类中才可以实现整体的House数据。我简单的做了一个 UML 图,不是很完善,但可以正常说明问题,请大家参考学习。

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×