blog icon indicating copy to clipboard operation
blog copied to clipboard

近一年的工作技术总结

Open SamHwang1990 opened this issue 7 years ago • 4 comments

上年四月份进入了毕业后第三家公司,到现在快一年了,忙了一年,可以好好总结下这年在JS 方面的实践过程和结果。

阶段一:融入团队

Javascript 搬砖小工冠着“前端架构师”的Title,“无耻地”进入了团队工作,哈哈哈

新进团队,当然要尽快了解团队的技术架构和技术水平。花了几天时间稍微熟悉代码和同事,然后就不淡定了。

首先,客户端的技术栈是: Juce + OpenGL + SpiderMonkeyJuce 提供跨平台的系统api 支持,比如文件系统读写、网络层、跨线程编程、系统级窗口管理、UI Component 等等。 在Juce Component 基础上,参考了HTML 和CSS,老大用OpenGL 开发了套跨平台的UI 渲染层,提供了需要的UI 布局和样式支持。最后,通过SpiderMonkey,将客户端的api 提供到Javascript 层调用。

哈,估计这个技术栈是独一家了吧,老大真心猛。

然后,就是前端这边的技术栈了。很好,这边前端是没有技术栈的,因为上面这独一无二的客户端架构,社区几乎没有适用的框架。没有技术栈是一个什么概念呢?请看下面一段代码:

// 创建两个布局元素
let $a = new DIV();
let $b= new DIV();

// 分别设置两个布局元素的样式
$a.setStyle('height: 100; width: 100;');
$b.setStyle('height: 50; width: 50;');

// 将$b 加到$a 的子元素中
$a.append($b);

项目中所有的元素布局都是这样写的,元素要一个个创建,一个个设置样式,一个个用append 加到文档流中。相当于没有xml 布局、没有stylesheet class 的支持。这种原始的创建DOM Tree 的写法表达性和维护性都比较差,急需优化啊!

然后,就是前端同事们的眼界和编码习惯了。当时,大部分前端同事编写的代码都比较随意,没有约束或团队约定,变量命名也是粗暴简单(比如:div1、div2、div3)。当时熟悉代码时简直不能更痛苦。另外,同事们也从不接触前端社区,不知道前端编程的各种玩法。

赞美和吐槽都说完了,“架构师” 也要开始搬砖了。上手第一件事是把co + generator + promise 的异步流程迁过来,告别callback hell。接着撸了一个功能模块,继续熟悉代码。然后,就踏上改造轮子、写轮子的道路了。

阶段二:Vue 迁移改造

上文提到,DOM Tree 的创建方式是最需要改善的,同时也希望能引入一个MVVM 的框架来提供比较好的开发体验和提高项目的可维护性。在简单对比社区中比较好的框架之后,基于下面几点考虑,我选择改造Vue 来作为我们项目的MVVM 框架:

  1. 项目所用的SpiderMonkey 的版本支持Object.defineProperty(),兼容性没问题;
  2. Vue 开发项目上手难度低,文档完成度非常高而且易懂;
  3. Vue 本身及项目不需要经过babel 编译即可运行;
  4. Vue 源代码由于已经有对weex 的适配,各模块间耦合度非常低,非常容易增加对新platform 的适配;
  5. Vue 生态链中的工具库齐全,质量也高;
  6. Vue 的单文件组件或template 能提供急需的使用xml 表达DOM Tree 的开发体验;
  7. 虽然我原先没真正用过Vue 写过项目,但从社区的反馈,我确信这是个质量极高的框架,哈哈;

当然,上面的第3、4 两点是最重要的两个因素。我当时简单看过React 的源码,很快就放弃了,cpu 不够好,哈哈。花了快两周的时间阅读并理解Vue 源码后,真心钦佩尤大大和Vue 团队,工程质量超级高。项目中每个模块的职能非常清晰,耦合度非常低,大部分逻辑都很容易理解。

熟悉Vue 项目逻辑后,就要开始二开了。我司客户端环境中,与浏览器标准差别最大的是:

  1. DOM Tree 构建的api 不同;
  2. Stylesheet 用法不同,相当于没有class 的概念,只有inline-style
  3. DOM Events 绑定和解绑的api 不同;

在参考了weex和 web 两个platforms 的代码后,整个迁移过程都算非常顺利了:

  • 将客户端api 封装成与web dom 同签名的api,比如Node.prototype.insertBefore()Node.prototype.appendChild()Node.prototype.childNodes,用于Vue 构建DOM Tree;
  • 将客户端api 封装成与web event 同签名的api;
  • 调整Vue event codegen 中各modifier 对应的api;
  • Vue runtime 中增加stylesheet 模块管理Node 节点的样式,将styleclass 模块中样式的应用归并到stylesheet 中完成;
  • 参考weex platform,增加entry-framework 入口逻辑,用于配置Vue 中依赖的platform 底层api,比如Document 对象获取、Element 创建与修改;
  • 参考weex platform,将runtime/node-ops.js 中对DOM Tree 修改的api 调整至entry-framework 中提供的api;

对,差不多就只需要做上面几件事,就可以将Vue 迁移到一个新的Platform 中,具体的分支代码可以参考:Ddder-FE/vue

阶段三:iphone 端开发

随着UDC 同事们完成软件IPhone 端的新版设计,前端组也开始对项目进行重新开发。上面二开的Vue 也要投入使用了。除了JS 端的MVVM 框架支撑,一个客户端还需要其他方面架构设计和工具库支撑,这个阶段我阅读了大神Casa 的好几篇文章,并借鉴了其中很多方案和工具库,最终也算给自己的项目备齐了必要的工具库:

网络层

网络层的构想是参考Casa 的文章:iOS应用架构谈 网络层设计方案 来设计的,文章中提到了一个概念:离散型API调用方式,也就是会抽象一个service 层来管理每个api 的调用,每个api 有自己独立的控制逻辑来达到下面的目的:

当前请求正在外面飞着的时候,根据不同的业务需求存在两种不同的请求起飞策略:一个是取消新发起的请求,等待外面飞着的请求着陆。另一个是取消外面飞着的请求,让新发起的请求起飞。

可惜在阶段三期间,我没有过多精力管业务开发,这层的构想也很模糊,落地后效果不是很理想,只是简单使用了axios.js 提供了友好的网络请求管理。所谓的service 层也只是将每个api 请求包装成函数然后暴露出去而已,每个api 请求函数间过于独立。

数据持久层-Sqlite

数据持久层的设计同样参考Casa 的文章:iOS应用架构谈 本地持久化方案及动态部署 来设计的。Casa 对他的持久化方案有对应ios 端的实现:CTPersistance。我这种没有自主设计能力的人,当然是拿来主义啦(很惭愧)。在阅读了源码后,又写了一份JS 版本的实现:SamHwang1990/sqlite-persistence

CTPersistance 的代码质量和模块设计水平也是超级高,思路很清晰,知道一个数据持久化库需要由哪些模块组成以及每个模块需要具备的功能,赞赞赞。

数据持久层-文件缓存

文件持久层则是在阅读了android 的DiskLruCache.java 后写了一份JS 版本的实现。然后,嫌DiskLruCache 的API 非常繁琐,于是又参考了ASimpleCache,增加了一个基于DiskLruCache.jsFDiskLruCache.js

代码参考:SamHwang1990/disk-lru-cache

Vue 项目状态管理-Vuex

熟悉Vue 的朋友都懂的,对应与Redux 等状态管理工具库,作用我就直接copy 文档了:

Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。

整个Iphone 端开发我们都是使用了Vuex 来管理应用程序的状态,不过说实话,用了一段时间后,我不是很喜欢Vuex 了,主要出于以下两个原因:

  1. 项目中,所有状态全扔Vuex 了,导致非vue 视图层的逻辑要访问这些数据时需要采用一些很绕的方式,下面会贴下代码,真的很不喜欢;
  2. Vuex 中的mutation、actions、getter 我个人觉得有点繁琐,而且调用actions、mutations 时或者访问getter 时都是基于字符串来引用,导致IDE 很难去索引,导致跟踪阅读代码时非常不方便;

当然,我上面两个体验可能是我们组内的使用方式不好吧,所以仅仅一家之言。

上面第1 点说到的很绕的访问vuex 状态的方法,举个栗子:

// 获取getters 方法
let getters = vuex.mapState({
    getInfo: 'personalInfo'
});

// 获取actions 操作方法
let actions = vuex.mapActions({
    updateInfo: 'updatePersonalInfo'
});

// 由于上面的getters、actions 依赖vm.$store,
// 所以写了个函数包了个假的context,里面包含下$store 属性,
// 然后就可以正常用getters 或actions 了。
function bindVuexStore (obj, store) {
    if (Array.isArray(obj)) {
        let tmp = {};
        obj.forEach(ele => _.merge(tmp, ele));
        obj = tmp;
    }
    for (let key in obj) {
        obj[key] = obj[key].bind({$store: store});
    }
    return obj;
}

let xPersonal = bindVuexStore([getters, actions], global.store);

// 调用`personalInfo` 的getter 函数
xPersonal.getInfo();

// 调用`updateInfo` actions
xPersonal.updateInfo({});

当然了,遇到不爽的地方,当然要想办法去做到更好了,而我摸索出自己项目组的解决方案,感觉还挺切合Vue 的,哈哈,这个方案会在下文提到。

移动端 APP 的页面管理:vue-navigation

页面管理,大致上需要几个功能:

  • 导航到指定页面;
  • 后退页面后退;
  • 重置到指定页面;
  • 带动效完成上面操作;

当时我也找了好几个库来参考,最终选择了造一个react-navigation 的轮子:vue-navigation 。基本上是把核心的逻辑拿过来用了,包括页面堆栈管理、CardStack 组件等等。代码感觉挺丑的,不过也还能用,哈哈。

代码参考:SamHwang1990/vue-navigation

检讨

整个IPhone 端开发周期下来,自己对工作的成果还是非常不满的,原因主要在项目质量本身。组员的前端水平参差不齐。有水准很高的锋神,也有水平比较差,协作意识同样差的组员。而当时自己又没更多的精力去控制每个人的模块设计和落地,导致慢慢地,项目某几个模块的质量开始失控,估计维护成本很高,反正看着很不爽。

待桌面端进行开发时,自己也需要投入到业务逻辑编写,尽量引导组员去改变编程思维和范式。同时,也能真正熟悉Vue 的项目开发,希望能找到自己认为的一些痛点的解决方案。

阶段四:桌面端开发

桌面端的开发从业务来说其实差不多,但好歹是一个新的开发阶段,所以,希望缓解IPhone 端开发中的一些痛点,同时,提高工程质量。

错开模块负责人

在IPhone 端开发中,部分组员的水平和三观有点不在线,而这些组员又是负责IPhone 端中比较重、比较核心的模块,导致着整个研发团队都有种说不出的难受,就是那种无时无刻都想对某某吐槽的感觉。

于是,我的选择是,桌面端的各模块的负责人完全错开,三观正、水平上线的同学接管核心模块,拉下限的同学转而负责一些小模块。希望能稳住核心模块的工程质量,这是最关键的,毕竟,现在去回顾IPhone 端的代码,简直灾难似的。

引入Webpack

桌面端的开发,做了下面几点考虑而没有作为独立项目来开发,而是与IPhone 公用一个仓库:

  • 只有一个前端团队,暂时也没有趋势会增加另一个团队;
  • 调试时的客户端依赖没有差别,可以共用,避免老大同时更新两个仓库;
  • IPhone 端和桌面端有些非视图层的逻辑是可以共用的,独立仓库会带来两倍的维护成本;

一个仓库里面包含两个平台的代码,为了保证各平台间发布的代码尽量独立,我引入了Webpack 来打包发布IPhone 端和PC 端两个entry 的代码。引入Webpack 还有一些潜在的好处,比如代码压缩优化、更全的es6、es7 支持、模块 alias 等等。

引入Typescript

在看到babel-loader 声称自己性能不够好的情况下,转而引入了typescript 来提供更全的es 特性支持,顺带还拥有了超好用的强类型辅助。好在typescript 的学习难度和适应难度都很低,直接应用到项目中也没有引起其他组员的不适,反而后期配合RxJS 的一些实践也非常得心应手。

解耦网络层与持久层:Agent

还记得在IPhone 端开发时,对网络层的定位和设计都过于简单,导致实现落地后,网络层、持久化层与视图层过于贴近,尤其是数据结构的处理上基本是按着视图层的需要来构造的,而且每个service 接口之间过于独立,没有整体感,带来的问题是每个service 间接受参数的形式不一致,返回的数据也没有一个约定。项目维护起来,如果每个部件都过于独立,没有共同的约定,真的会很头大。

项目中的数据上游一般有三个源:用户输入、数据库读操作、服务端,而下游一般会到达:客户端应用内存、数据库写操作、服务端、视图界面。而用户输入的这个上游,我一般也看作是客户端应用内存。总结下来,数据不管上游下游,只在三个地方存在:应用内存、数据库、服务端,感觉好废话哈。

仔细想想,对业务层来说,数据本身从数据库读取或从服务端读取都无所谓,反正你给我一个地方读到我想要的数据。同样,业务层要写入数据,其实它也不用管是服务端还是数据库接收,反正有一个地方能好好对待我给出的数据就行。所以,对于业务层来说,数据库、服务端这两层是可以混在一起的,这两层混在一起,对于跨平台应用也是适用的,只要平台间业务功能没太大出入,用的还是同一套服务端数据结构,自然,各平台客户端的服务端api 甚至数据库持久层的结构都是十分相近的。

综上,我们需要一套方案来实现下面的功能:

  1. 数据持久层、网络层合并,封装成api 供业务层读写,进而解耦业务层、持久层、网络层;
  2. 业务层可以接触到的api 都有着一致的调用方式、一致的返回体、一致的错误反馈方式;

好了,终于可以介绍下我给出设计方案,还是挺复杂的。。。

对接多个微服务:HttpNetworking

参考RTNetworking 设计了HttpNetworking(待上传代码),用于对接不同后端微服务。每个微服务有自己的Service,提供ApiProvider 来为每个Service 下可注册多个Api。

目前来看,这个库的设计带入了太多的层级(Service、RequestGenerator、ApiProvider),实际使用中,甚至连我自己都要经常查看代码来确定options 是否可用,可以说,记忆成本太大。

代码参考:SamHwang1990/http-networking

客户端服务层:Agent

回到需求分析,我需要一个可以合并数据持久层、网络层逻辑,同时对外提供一致的api 调用方式的方案,想着想着,感觉这个需求跟后端服务器概念有点类似:

  • 对外一致的api 调用,类似于客户端按着约定的api 格式(REST 或JSON 或GraphQL)向服务器发请求;
  • 合并数据持久层、网络层逻辑,这不正式服务器一直以来的工作吗?处理客户端发来的请求,往数据库读写数据;

于是,我参考了koa,在项目中引入了一个微服务层 Agent,每个api 运行时都有一个context 对象,context 对象有req 对象保存请求数据,rep 对象保存返回数据,简单demo 如下:

// ---------------------------
// Agent 中api 的编写方式
// user.js
function* login() {
  	
  	// 获取请求体
    const { name, pwd, autoLogin } = this.req.params;
  
  	// 调用某个网络层api
    const responseData = yield AuthApi.login({ phone, password, code });

    const session = responseData.session

    // 持久层操作
    yield persistSession(session);

  	// 设置返回体
    this.res.data = responseData;
}

// ---------------------------
// 调用Agent 中的api
const response = yield Agent('user.login', {
    params: {
        name: 123,
      	pwd: 123,
      	autoLogin: true,
    }
});

// response 主要有两个属性:code、data
if (response.code === 'S_OK') {
  	console.log(response.data);
    // 登录成功了
} else {
    // 出错了
}

代码参考:SamHwang1990/agent

跨层的应用状态管理:RxJS + vue-rx + XStateSubject

还记得我在IPhone 开发阶段中对表达过Vuex 状态管理的不满足,经过一段时间的思考和摸索,我为团队提供了下面的一套新的、以数据流为基础的、跨越视图层和业务service 层的应用状态管理方案:

  1. 增加xService 概念,每个业务模块都可以拥有的独立的controller,用于连接agent 层和视图层。xService 中可按需要创建和持有模块的状态数据;
  2. 上面提到的模块状态数据,我参考了ngrx/platform中的store 设计,在项目中引入了XStateSubject,一种继承于Observable.BehaviorSubject,提供类似于Vuex mutation 的方式来修改数据的对象;
  3. xService 中持有的XStateSubject 建议都会暴露一个只读的流给外界(比如Vue 实例)订阅,而修改XStateSubject 则需要通过dispatch 某个action,并附带对应payload 来完成;
  4. Vue 视图层则配合vue-rx 中的subscriptions 来替代原来的computed

在实践中,甚至连Vue 视图层的data 我都很大程度上弃用。Vue 实例中对data 的读取是很方便的,this.dataName 即可,但在设置data 数据时就显得过于没有成本了,简单的this.dataName = 'say something',就能修改状态数据,这种低成本setter 操作让我很没有安全感。于是配合subscriptionsXStateSubject,我有意地将让数据更新的操作变得更加有指引性。

代码参考:SamHwang1990/xservice

Vue 组件化的新发现:跨Context 渲染VNode Tree

在PC 端的开发中,有个很常见的需求:独立窗口,即调用客户端api 创建一个子窗口,然后在该子窗口document 上构建DOM Tree。简单想一下,可能要写类似下面的伪代码来实现:

const component1 = require('./compoent1.vue');
const component2 = require('./compoent2.vue');

const childWindow = global.currentWindow.createChildWindow();
const childWindowDocument = childWindow.getDocument();

const foo = 1;
const bar = 2;

const childApp = new Vue({
    el: childWindowDocument.createNode(),
  	template: '<div>Child Window Vue Application <c1 :foo="foo"></c1>  <c2 :bar="bar"></c2> </div>',
  	components: {
        c1: component1,
      	c2: component2,
    },
  	data: {
        foo,
      	bar,
    }
})

上面的方式是单纯在JS 中新建Vue 实例来完成DOM Tree 构建,好像叫调用式。这种方式有下面几个比较麻烦事:

  1. 父Vue 实例中维护childApp、childWindow 这几个对象会比较啰嗦,需要在把这些对象通过某种方式暴露给Vue 实例,然后在beforeDestroy 钩子中完成清楚工作,同时,当上面逻辑被重复调用时也要确保不会重建重复的子window 和子vue 实例;
  2. 若childApp 中的data 是从父Vue 实例的响应数据读取,同是存在响应依赖的场景,那可能就要为了迁就childApp 来将父Vue 实例的数据专门用个对象保存,然后childApp 中再依赖这个对象,想着都麻烦;

苦死冥想一番后,看着VNode 中的context,忽然领悟到,我可以把子window 的DOM Tree 写在当前Vue 实例的template 中,然后在render 阶段把属于子window 的VNode Tree 移到新window 的根Vue 实例中做patch。而因为VNode 在创建是已经确定了数据访问的上下文,也就是context,所以,在patch 阶段,即使子window 的VNode Tree 渲染在另一个文档流,但其依赖的全部数据全来源于当前的Vue 实例。对了,说明一下,我们客户端的多窗口都在同一个UI 线程中运行的。举个例子:

<template>
	<div>
      	Parent VM
      	<browser-window>
          	<div>
              Child Window VM
              {{ foo }}
			</div>
  		</browser-window>
  	</div>
</template>
<script lang='ts'>
  export default {
      data() {
          return {
              foo: 'bar'
          }
      }
  }
</script>

如上,我写了个browser-window 的组件,这个组件主要完成几件事:

  1. 组件本身在父Document 中渲染为一个不可见的,无后代元素的空节点;
  2. 组件内部完成创建子窗口的工作;
  3. 组件内部完成子窗口中根Vue 实例创建工作;
  4. 组件在render 时,将子Vnode Tree patch 到子窗口的根Vue 实例;
  5. 组件update 时,触发子Vnode Tree 的patch,进而将新UI 更新到子窗口;
  6. 父Vue 实例销毁时同步销毁子窗口和子窗口Vue 实例,自然组件的子Vnode Tree 也得到clean;

其实我在Vue 社区中看到过不少库提供类似browser-window 的功能,将某个Vue 实例中某一部分的UI 放到其他Node 节点上渲染,通常的实现都是在mounted 时将patch 生成的HtmlElement append 到其他HtmlElement 中。

TODO: 待上传代码

下个阶段

完成桌面端开发后,接下来的scheduler 大致是:

  • 增强对数据流、xService api、agent api 的单元测试;
  • 了解WebKit 实现;
  • 重构HttpNetworking;
  • 完善Sqlite Persistence 中的Migration 逻辑;
  • 可能转Java 后端;

文中有很多的内容都是一笔带过,talk is cheap, show me the code and your stream.


如果对我司前端组的工作内容和架构有兴趣的,可以发邮件给我内推哈,很缺人;

如果对我的工作能力和工作内容认可的,缺前端小伙伴的朋友,也可以进一步沟通,看有没有合作机会哈!!!

SamHwang1990 avatar Mar 28 '18 16:03 SamHwang1990

向大神学习!!

leodu237 avatar Mar 29 '18 02:03 leodu237

转 java 后端亮了

aleen42 avatar Mar 30 '18 01:03 aleen42

好6啊,想知道是什么样的业务,需要自己重新造这么多的轮子

hugeorange avatar Jun 12 '18 15:06 hugeorange

主要是客户端底层的技术栈跟浏览器不太一样,导致很多业界的库都要自己改造来适配底层的api。加上这边JS 还可以调用系统级的API,思路有点像weex,有些网络层、sqlite 层的库都要写。

orange [email protected]于2018年6月12日周二 下午11:16写道:

好6啊,想知道是什么样的业务,需要自己重新造这么多的轮子

— You are receiving this because you authored the thread. Reply to this email directly, view it on GitHub https://github.com/SamHwang1990/blog/issues/11#issuecomment-396627158, or mute the thread https://github.com/notifications/unsubscribe-auth/AELz7xdyzMH-JiPNN1k9XUNWbge-Gjr1ks5t79s0gaJpZM4S--9k .

--

SamHwang1990 avatar Jun 14 '18 02:06 SamHwang1990