fisecoco

fisecoco 查看完整档案

成都编辑  |  填写毕业院校  |  填写所在公司/组织填写个人主网站
编辑

学后端的前端

个人动态

fisecoco 赞了文章 · 1月8日

前端面试题 -- 小程序

前言

首先说一些为什么总结小程序相关的面试题吧

我们可以随便打开一个招聘网站,在那里你会发现市场对于小程序的需求还是蛮高的,有些公司可能就只需要写小程序的前端人员

虽然小程序的开发很大一部分都是很简单的,但是有些常用的东西还是有必要了解一下的。故此,有了这样一篇小程序面试题的总结。当然感兴趣或者有需要的小伙伴也可以 点击这里,查看完整版前端面试题

如果文章中有出现纰漏、错误之处,还请看到的小伙伴留言指正,先行谢过

以下 ↓

1. 简单描述下微信小程序的相关文件类型

微信小程序项目结构主要有四个文件类型
  • WXML(WeiXin Markup Language)是框架设计的一套标签语言,结合基础组件、事件系统,可以构建出页面的结构。内部主要是微信自己定义的一套组件
  • WXSS (WeiXin Style Sheets)是一套样式语言,用于描述 WXML 的组件样式
  • js 逻辑处理,网络请求
  • json 小程序设置,如页面注册,页面标题及tabBar
主要文件
  • app.json 必须要有这个文件,如果没有这个文件,项目无法运行,因为微信框架把这个作为配置文件入口,整个小程序的全局配置。包括页面注册,网络设置,以及小程序的 window 背景色,配置导航条样式,配置默认标题
  • app.js 必须要有这个文件,没有也是会报错!但是这个文件创建一下就行 什么都不需要写以后我们可以在这个文件中监听并处理小程序的生命周期函数、声明全局变量
  • app.wxss 可选

2. 简述微信小程序原理

微信小程序采用 JavaScriptWXMLWXSS 三种技术进行开发,本质就是一个单页面应用,所有的页面渲染和事件处理,都在一个页面内进行,但又可以通过微信客户端调用原生的各种接口

微信的架构,是数据驱动的架构模式,它的 UI 和数据是分离的,所有的页面更新,都需要通过对数据的更改来实现

小程序分为两个部分 webviewappService 。其中 webview 主要用来展现 UI appService 有来处理业务逻辑、数据及接口调用。它们在两个进程中运行,通过系统层 JSBridge 实现通信,实现 UI 的渲染、事件的处理

3. 小程序的双向绑定和vue哪里不一样

小程序直接 this.data 的属性是不可以同步到视图的,必须调用:

this.setData({
    // 这里设置
})

4. 小程序的wxss和css有哪些不一样的地方

WXSSCSS 类似,不过在 CSS 的基础上做了一些补充和修改
  • 尺寸单位 rpx

rpx 是响应式像素,可以根据屏幕宽度进行自适应。规定屏幕宽为 750rpx。如在 iPhone6 上,屏幕宽度为 375px,共有 750 个物理像素,则 750rpx = 375px = 750 物理像素

  • 使用 @import 标识符来导入外联样式。@import 后跟需要导入的外联样式表的相对路径,用;表示语句结束
/** index.wxss **/
@import './base.wxss';

.container{
    color: red;
}

5. 小程序页面间有哪些传递数据的方法

  • 使用全局变量实现数据传递

app.js 文件中定义全局变量 globalData, 将需要存储的信息存放在里面

// app.js

App({
     // 全局变量
  globalData: {
    userInfo: null
  }
})

使用的时候,直接使用 getApp() 拿到存储的信息

  • 使用 wx.navigateTowx.redirectTo 的时候,可以将部分数据放在 url 里面,并在新页面 onLoad 的时候初始化
//pageA.js

// Navigate
wx.navigateTo({
  url: '../pageD/pageD?name=raymond&gender=male',
})

// Redirect
wx.redirectTo({
  url: '../pageD/pageD?name=raymond&gender=male',
})


// pageB.js
...
Page({
  onLoad: function(option){
    console.log(option.name + 'is' + option.gender)
    this.setData({
      option: option
    })
  }
})

需要注意的问题:

wx.navigateTowx.redirectTo 不允许跳转到 tab 所包含的页面

onLoad 只执行一次

  • 使用本地缓存 Storage 相关

6. 小程序的生命周期函数

  • onLoad 页面加载时触发。一个页面只会调用一次,可以在 onLoad 的参数中获取打开当前页面路径中的参数
  • onShow() 页面显示/切入前台时触发
  • onReady() 页面初次渲染完成时触发。一个页面只会调用一次,代表页面已经准备妥当,可以和视图层进行交互
  • onHide() 页面隐藏/切入后台时触发。 如 navigateTo 或底部 tab 切换到其他页面,小程序切入后台等
  • onUnload() 页面卸载时触发。如 redirectTonavigateBack 到其他页面时

详见 生命周期回调函数

7. 怎么封装微信小程序的数据请求

参考 这里

8. 哪些方法可以用来提高微信小程序的应用速度

1、提高页面加载速度

2、用户行为预测

3、减少默认 data 的大小

4、组件化方案

9. 微信小程序的优劣势

优势
  • 即用即走,不用安装,省流量,省安装时间,不占用桌面
  • 依托微信流量,天生推广传播优势
  • 开发成本比 App
缺点
  • 用户留存,即用即走是优势,也存在一些问题
  • 入口相对传统 App 要深很多
  • 限制较多,页面大小不能超过2M。不能打开超过10个层级的页面

10. 怎么解决小程序的异步请求问题

小程序支持大部分 ES6 语法
  • 在返回成功的回调里面处理逻辑
  • Promise 异步

11. 小程序关联微信公众号如何确定用户的唯一性

如果开发者拥有多个移动应用、网站应用、和公众帐号(包括小程序),可通过 unionid 来区分用户的唯一性,因为只要是同一个微信开放平台帐号下的移动应用、网站应用和公众帐号(包括小程序),用户的 unionid 是唯一的。换句话说,同一用户,对同一个微信开放平台下的不同应用,unionid 是相同的

12. 如何实现下拉刷新

  • 首先在全局 config 中的 window 配置 enablePullDownRefresh
  • Page 中定义 onPullDownRefresh 钩子函数,到达下拉刷新条件后,该钩子函数执行,发起请求方法
  • 请求返回后,调用 wx.stopPullDownRefresh 停止下拉刷新

参考 这里

13. bindtap和catchtap的区别是什么

相同点:首先他们都是作为点击事件函数,就是点击时触发。在这个作用上他们是一样的,可以不做区分

不同点:他们的不同点主要是bindtap是不会阻止冒泡事件的,catchtap是阻值冒泡的

14. 简述下 wx.navigateTo(), wx.redirectTo(), wx.switchTab(), wx.navigateBack(), wx.reLaunch()的区别</h5>

  • wx.navigateTo():保留当前页面,跳转到应用内的某个页面。但是不能跳到 tabbar 页面
  • wx.redirectTo():关闭当前页面,跳转到应用内的某个页面。但是不允许跳转到 tabbar 页面
  • wx.switchTab():跳转到 abBar 页面,并关闭其他所有非 tabBar 页面
  • wx.navigateBack()关闭当前页面,返回上一页面或多级页面。可通过 getCurrentPages() 获取当前的页面栈,决定需要返回几层
  • wx.reLaunch():关闭所有页面,打开到应用内的某个页面

后记

很多东西真正做一遍,收获一定是最大的,那些途中踩过的坑、迸发的灵性火花都是我们成长路上宝贵的东西

后面还是会不定期更新一些面试题或者有意思的东西,分享给有需要的小伙伴

感兴趣的小伙伴可以 点击这里 ,也可以扫描下方二维码关注我的微信公众号,查看更多前端小片段,欢迎 star 关注

image

查看原文

赞 112 收藏 79 评论 0

fisecoco 赞了文章 · 1月8日

前端最强面经汇总

花了很长时间整理的前端面试资源,喜欢请大家不要吝啬star~

别只收藏,点个赞,点个star再走哈~

持续更新中……,可以关注下github

项目地址

https://github.com/abc-club/f...

求star!!!
求star!!!
求star!!!

面试秘籍

面试题集

面试题

面试技巧

面试经验

github面试题仓库

如果你是面试官

题目

js

  1. getcomputedstyle和style的区别
1.只读与可写
  getComputedStyle方法是只读的,只能获取样式,不能设置;而element.style能读能写,能屈能伸。
2.获取的对象范围
  getComputedStyle方法获取的是最终应用在元素上的所有CSS属性对象(即使没有CSS代码,也会把默认的祖宗八代都显示出来);
  而element.style只能获取元素style属性中的CSS样式。
  因此对于一个光秃秃的元素<p>,getComputedStyle方法返回对象中length属性值(如果有)就是190+(据我测试FF:192, IE9:195, Chrome:253, 不同环境结果可能有差异), 
  而element.style就是0。
3.作用
  getComputedStyle方法有一个很重要的,类似css()方法没有的功能——获取伪类元素样式
4.兼容性
  getComputedStyle方法IE6~8是不支持的
查看原文

赞 364 收藏 301 评论 5

fisecoco 赞了文章 · 2019-10-25

Spring Boot 学习资料收集

导读:

从第一次接触Spring Boot 至今已经有半年多了,在这期间也浏览了许多和Spring Boot 相关的书籍及文章,公司里面的许多项目也一直在使用Spring Boot。
关于Spring Boot的一些看法:
Spring Boot 给我的第一印象便是简洁,其中的核心思想便是约定大于配置。
关于这篇文章的定位:
这篇文章主要是作为一个Spring Boot 的一篇入门文章,帮助你在最短的时间内上手Spring Boot ,让你摆脱繁琐的配置文件,如果将Spring 比作javaEE开发者的第一春,那么Spring Boot 便是第二春。

Spring Boot 系列文章——更新计划列表:

主要对一些Spring Boot中常用的框架进行简单的介绍及快速上手,外加相关资料的收集;
更新列表会不定期的加入新的内容以进行扩充,如果你对此感兴趣可以站内联系我。
结尾有彩蛋 ???


入门篇:

安利 使用 IntelliJ IDEA开发基于 Spring Boot 应用 会更加高效>_<

常用功能:

杂项:

扩展
TODO:

  1. SanJi Boot 前后端分离 前端基于 webpack 后端 基于 Spring Boot
    说明:

    后端采用Restfull风格url 对之前的Sanji Boot进行改写 采用 JWT 认证 (后期会做调整)
    前端采用Material风格采用技术 webpack + Vue + Vue Router + Axios + iviewUI

    进度: 预计2017/12/30之前 完成

  2. Oh-Today 今日热点聚合 Spring Boot + Vue

进阶篇:

上手案例:

github上的一些上手案例:

(整合分页插件pagehelper+Swagger2构建RESTful API文档) spring boot + mybatis + druid + Swagger2

spring boot 的上手练习与后面文章有关

前后端分离(前端组件化与模块化正在实施......)

spring-security + jwt

  • 一个简单案例(正在构思)

收藏夹[网页书签工具-通过网站实现跨浏览器] created by: 17/04/11 预计在7月份之前产出(updated time:17/06/27)一个懒癌患者 >_< (看样子是要一拖再拖了 updated time:17/08/12)

收藏夹
 +- 用户
     +- 根文件夹
         +- 文件(链接)...
         |
         +- 文件夹(包含文件夹,与文件)
             +- 文件
             +- 文件夹

更多组件:

高级篇:

Spring Cloud系列文章

spring cloud 学习资料收集

快速了解Spring Boot:

Spring Boot 快速上手的示例:

我给出的一个示例 (Spring Boot 版本为1.4.0.RELEASE)


@RestController
@SpringBootApplication
public class App{

    @GetMapping
    String home() {
        return "Hello World!";
    }

    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }

}

spring官方给出了一个简单的示例:

@Controller
@EnableAutoConfiguration
public class SampleController {

    @RequestMapping("/")
    @ResponseBody
    String home() {
        return "Hello World!";
    }

    public static void main(String[] args) throws Exception {
        SpringApplication.run(SampleController.class, args);
    }
}

Spring Boot 学习资料:

官方文档:
Spring Boot Reference Guide 1.5.2
网络书籍:
Spring Boot Reference Guide中文翻译 -《Spring Boot参考指南》 github
Spring Boot Reference Guide中文翻译 -《Spring Boot参考指南》 1.3.0.BUILD-SNAPSHOT doc
Spring Boot Reference Guide中文翻译 -《Spring Boot参考指南》 1.4.1.RELEASE doc
博客文章:
程序员DD
Spring Boot——开发新一代Spring Java应用
Spring Boot 入门
使用 Spring Boot 快速构建 Spring 框架应用 - IBM
spring Boot - 简书
《Spring Boot官方指南》(二)入门 - 并发编程网
liaokailin的专栏
视频:
2小时学会SpringBoot - 慕课网

更多(收集自网友):
Spring Boot 教程系列学习 —— 尨果学院(比较基础 简单)
Spring Boot 那些事 —— 泥瓦匠BYSocket
Spring Boot —— 纯洁的微笑
Spring Boot 揭秘与实战 系列 —— 梁桂钊(很全面)
Spring Boot干货系列总纲 —— 嘟嘟独立博客
程序猿DD —— 简书
杜琪 —— 简书
林祥纤的博客 - 从零开始学Spring Boot分类文章列表 - ITeye技术网站
网易云课堂:林祥纤:从零开始学Spring Boot
优酷视频
感谢网友 :
火炎焱
林祥纤

探索

更多周边及晋级资料收集

⚡️ awesome-book

查看原文

赞 95 收藏 245 评论 3

fisecoco 赞了文章 · 2019-05-11

What's New in JavaScript

前几天 Google IO 上 V8 团队为我们分享了《What's New in JavaScript》主题,分享的语速很慢推荐大家可以都去听听就当锻炼下听力了。看完之后我整理了一个文字版帮助大家快速了解分享内容,嘉宾主要是分享了以下几点:

  1. JS 解析快了 2 倍
  2. async 执行快了 11 倍
  3. 平均减少了 20% 的内存使用
  4. class fileds 可以直接在 class 中初始化变量不用写在 constructor 里
  5. 私有变量前缀
  6. string.matchAll 用来做正则多次匹配
  7. numeric seperator 允许我们在写数字的时候使用 _ 作为分隔符提高可读性
  8. bigint 新的大数字类型支持
  9. Intl.NumberFormat 本地化格式化数字显示
  10. Array.prototype.flat(), Array.prototype.flatMap() 多层数组打平方法
  11. Object.entries() 和 Object.fromEntries() 快速对对象进行数组操作
  12. globalThis 无环境依赖的全局 this 支持
  13. Array.prototype.sort() 的排序结果稳定输出
  14. Intl.RelativeTimeFormat(), Intl.DateTimeFormat() 本地化显示时间
  15. Intl.ListFormat() 本地化显示多个名词列表
  16. Intl.locale() 提供某一本地化语言的各种常量查询
  17. 顶级 await 无需写 async 的支持
  18. Promise.allSettled() 和 Promise.any() 的增加丰富 Promise 场景
  19. WeakRef 类型用来做部分变量弱引用减少内存泄露

Async 执行比之前快了11倍

开场就用 11x faster 数字把大家惊到了,也有很多同学好奇到底是怎么做到的。其实这个优化并不是最近做的,去年11月的时候 V8 团队就发了一篇文章 《Faster async functions and promises》,这里面就非常详尽的讲述了如何让 async/await 优化到这个速度的,其主要归功于以下三点:

  • TurboFan:新的 JS 编译器
  • Orinoco:新的 GC 引擎
  • Node.js 8 上的一个 await bug

2008年 Chrome 出世,10年 Chrome 引入了 Crankshaft 编译器,多年后的今天这员老将已经无法满足现有的优化需求,毕竟当时的作者也未曾料想到前端的世界会发展的这么快。关于为何使用 TurboFan 替换掉 Crankshaft,大家可以看看《Launching Ignition and TurboFan》,原文中是这么说的:

Crankshaft 仅支持优化 JavaScript 的一部分特性。它并没有通过结构化的异常处理来设计代码,即代码块并没有通过try、catch、finally等关键字划分。另外由于为每一个新的特性Cranksshaft都将要做九套不同的框架代码适应不同的平台,因此在适配新的Javascript语言特性也很困难。还有Crankshaft框架代码的设计也限制优化机器码的扩展。尽管V8引擎团队为每一套芯片架构维护超过一万行代码,Crankshaft也不过为Javascript挤出一点点性能。
via:《Javascript是如何工作的:V8引擎的内核Ignition和TurboFan》

而 TurboFan 则提供了更好的架构,能够在不修改架构的情况下添加新的优化特性,这为面向未来优化 JavaScript 语言特性提供了很好的架构支持,能让团队花费更少的时间在做处理不同平台的特性和编码上。从原文的数据对比中就可以看到,仅仅是换了个编译器优化就在 8 倍左右了…… 给 V8 的大佬们跪下了。

而 Orinoco 新的 GC 引擎则是使用单独线程异步去处理,让其不影响 JS 主线程的执行。至于最后说的 async/await 的 BUG 则是让 V8 团队引发思考,将 async/await 原本基于 3 个 Promise 的实现减少成 2 个,最终减少成 1 个!最后达到了写 async/await 比直接写 Promise 还要快的结果。

我们知道 await 后面跟的是 Promise 对象,但是即使不是 Promise JS 也会帮我们将其包装成 Promise。而在 V8 引擎中,为了实现这个包装,至少需要一个 Promise,两个微任务过程。这个在本身已经是 Promise 的情况下就有点亏大发了。而为了实现 async/await 在 await 结束之后要重新原函数的上下文并提供 await 之后的结果,规范定义此时还需要一个 Promise,这个在 V8 团队看来是没有必要的,所以他们建议规范去除这个特性

最后的最后,官方还建议我们:多使用 async/await 而不是手写 Promise 代码,多使用 JavaScript 引擎提供的 Promise 而不是自己去实现。

Numeric Seperator

随着 Babel 的出现,JS 的语法糖简直不要太多,Numeric Seperator 算是一个。简单的说来它为我们手写数字的时候提供给了分隔符的支持,让我们在写大数字的时候具有可读性。

其实是个很简单的语法糖,为什么我会单独列出来说呢,主要是因为它正好解决了我之前一个实现的痛点。我有一个需求是一堆文章数据,我要按照产品给的规则去插入广告。如图非红框是文章,红框处是广告。由于插入规则会根据产品的需(心)求(情)频繁变化,所以我们最开始使用了两个变量进行标记:

const news = [1, 3, 5, 6, 7, 9, 10, 11];
const ads = [2, 4, 8, 12];

当位置发生变化的时候我们就需要同时对两个变量进行修改,这样导致了维护上的成本。所以我想了一个办法,广告的位置标记为 1,文章的位置标记为 0,使用纯二进制的形式来表示个记录,这样子就变成了:

+---+---+---+
| 0 | 1 | 0 |
+---+---+---+
| 1 | 0 | 0 |
+---+---+---+
| 0 | 1 | 0 |
+---+---+---+
| 0 | 0 | 1 |
+---+---+---+

1 011 010 100 010 001
// 首位为常量 1
// 2-4 位记录一行多少条
// 后续按照新闻和广告的位置进行记录

最后我们使用一个变量 0b1011010100010001 就完成了两种信息的记录。这样做将很多数据集成在了一起解决了我们之前的问题,但是它带来了新的问题,大家也可以看到注释中按照空格劈开的话大家看的还比较明白,但是在段头将空格去除之后在阅读程度上就造成了非常大的困难了。而数字分隔符这个语法糖正好就能解决这个问题,0b1_011_010_100_010_001 这样阅读起来就好很多了。

Promise

虽然在大部分的场景 async/await 都可以了,但是不好意思 Promise 有些场景还是不可替代的。Promsie.all()Promise.race() 就是这种特别的存在。而 Promise.allSettled()Promise.any() 则是新增加的方法, 相较于它们的前辈,这俩拥有忽略错误达到目的的特性。

我们之前有一个需求,是需要将文件安全的存储在一个存储服务中,为了灾备我们其实有两个 S3,一个 HBase 还有本地存储。所以每次都需要诸如以下的逻辑:

for(const service of services) {
  const result = await service.upload(file);
  if(result) break;
}

但其实我并不关心错误,我的目的是只要保证有一个服务最终能执行成功即可,所以 Promise.any() 其实就可以解决这个问题。

await Promise.any( services.map(service => service.upload(file)) );

Promise.allsettled()Promise.any() 的引入丰富了 Promise 更多的可能性。说不定以后还会增加更多的特性,例如 Promise.try(), Promise.some(), Promise.reduce() ...

WeakRef

WeakRef 这个新类型我最开始是不太理解的,毕竟我总感觉 Chrome 已经长大了,肯定会自己处理垃圾了。然而事情并没有我想的那么简单,我们知道 JS 的垃圾回收主要有“标记清除”和“引用计数”两种方法。引用计数是只要变量多一次引用则计数加 1,少一次引用则计数减 1,当引用为 0 时则表示你已经没有利用价值了,去垃圾站吧!

在 WeakRef 之前其实已经有两个类似的类型存在了,那就是 WeakMap 和 WeakSet。以 WeakMap 为例,它规定了它的 Key 必须是对象,而且它的对象都是弱引用的。举个例子:

//map.js
function usageSize() {
  const used = process.memoryUsage().heapUsed;
  return Math.round(used / 1024 / 1024 * 100) / 100 + 'M';
}

global.gc();
console.log(usageSize()); // ≈ 3.23M

let arr = new Array(5 * 1024 * 1024);
const map = new Map();

map.set(arr, 1);
global.gc();
console.log(usageSize()); // ≈ 43.22M

arr = null;
global.gc();
console.log(usageSize()); // ≈ 43.23M
//weakmap.js
function usageSize() {
  const used = process.memoryUsage().heapUsed;
  return Math.round(used / 1024 / 1024 * 100) / 100 + 'M';
}

global.gc();
console.log(usageSize()); // ≈ 3.23M

let arr = new Array(5 * 1024 * 1024);
const map = new WeakMap();

map.set(arr, 1);
global.gc();
console.log(usageSize()); // ≈ 43.22M

arr = null;
global.gc();
console.log(usageSize()); // ≈ 3.23M

分别执行 node --expose-gc map.jsnode --expose-gc weakmap.js 就可以发现区别了。在 arr 和 Map 中都保留了数组的强引用,所以在 Map 中简单的清除 arr 变量内存并没有得到释放,因为 Map 还存在引用计数。而在 WeakMap 中,它的键是弱引用,不计入引用计数中,所以当 arr 被清除之后,数组会因为引用计数为0而被回收掉。

正如分享中所说,WeakMap 和 WeakSet 足够好,但是它要求键必须是对象,在某些场景上不太试用。所以他们暴露了更方便的 WeakRef 类型。在 Python 中也存在 WeakRef 类型,干的事情比较类似。其实我们主要注意 WeakRef 的引用是不计引用计数的,就好理解了。例如 MDN 中所说的引用计数没办法清理的循环引用问题:

function f(){
  var o = {};
  var o2 = {};
  o.a = o2; // o 引用 o2
  o2.a = o; // o2 引用 o

  return "azerty";
}

f();

如果试用 WeakRef 来改写,由于 WeakRef 不计算引用计数,所以计数一直为 0,在下一次回收中就会被正常回收了。

function f() {
  var o = new WeakRef({});
  var o2 = o;
  o.a = o2;

  return "azerty";
}

f();

在之前的一个多进程需求中,我们需要将子进程中的数据发送到主进程中,我们使用的方式是这样写的:

const metric = 'event';
global.DATA[metric] = {};

process.on(metric, () => {
  const data = global.DATA[metric];
  delete global.DATA[metric];
  return data;
});

代码就看着比较怪,由于 global.DATA[metric] 作为强引用,如果直接在事件中 return global.DATA[metric] 的话,由于存在引用计数,那么这个全局变量是一直占用内存的。此时如果使用 WeakRef 改写一下就可以减少 delete 的逻辑了。

const metric = 'event';
global.DATA[metric] = new WeakRef({});

process.on(metric, () => {
  const ref = global.DATA[metric];
  if(ref !== undefined) {
    return ref.deref();
  }
  return ref;
});

后记

除了我上面讲的几个特性之外,还有很多其他的特性也非常一颗赛艇。例如 String.matchAll() 让我们做多次匹配的时候再也不用写 while 了!Intl 本地化类的支持,让我们可以早日抛弃 moment.js,特别是 RelativeTimeFormat 类真是解放了我们的生产力,不过目前接口的配置似乎比较定制化,不知道后续的细粒度需求支持情况会如何。

参考资料:

查看原文

赞 49 收藏 32 评论 0

fisecoco 回答了问题 · 2019-04-02

如何捕获websocket异常

js中是不能catch住websocket的超时error的,这个是设计的限制。可以参考https://stackoverflow.com/que...

关注 4 回答 2

fisecoco 关注了专栏 · 2019-01-28

Java知识点大全

Java3y原创技术文章

关注 2722

fisecoco 赞了文章 · 2018-07-09

Javascript装饰器的妙用

最近新开了一个Node项目,采用TypeScript来开发,在数据库及路由管理方面用了不少的装饰器,发觉这的确是一个好东西。
装饰器是一个还处于草案中的特性,目前木有直接支持该语法的环境,但是可以通过 babel 之类的进行转换为旧语法来实现效果,所以在TypeScript中,可以放心的使用@Decorator

什么是装饰器

装饰器是对类、函数、属性之类的一种装饰,可以针对其添加一些额外的行为。
通俗的理解可以认为就是在原有代码外层包装了一层处理逻辑。
个人认为装饰器是一种解决方案,而并非是狭义的@Decorator,后者仅仅是一个语法糖罢了。

装饰器在身边的例子随处可见,一个简单的例子,水龙头上边的起泡器就是一个装饰器,在装上以后就会把空气混入水流中,掺杂很多泡泡在水里。
但是起泡器安装与否对水龙头本身并没有什么影响,即使拆掉起泡器,也会照样工作,水龙头的作用在于阀门的控制,至于水中掺不掺杂气泡则不是水龙头需要关心的。

所以,对于装饰器,可以简单地理解为是非侵入式的行为修改。

为什么要用装饰器

可能有些时候,我们会对传入参数的类型判断、对返回值的排序、过滤,对函数添加节流、防抖或其他的功能性代码,基于多个类的继承,各种各样的与函数逻辑本身无关的、重复性的代码。

函数中的作用

可以想像一下,我们有一个工具类,提供了一个获取数据的函数:

class Model1 {
  getData() {
    // 此处省略获取数据的逻辑
    return [{
      id: 1,
      name: 'Niko'
    }, {
      id: 2,
      name: 'Bellic'
    }]
  }
}

console.log(new Model1().getData())     // [ { id: 1, name: 'Niko'}, { id: 2, name: 'Bellic' } ]
console.log(Model1.prototype.getData()) // [ { id: 1, name: 'Niko'}, { id: 2, name: 'Bellic' } ]

现在我们想要添加一个功能,记录该函数执行的耗时。
因为这个函数被很多人使用,在调用方添加耗时统计逻辑是不可取的,所以我们要在Model1中进行修改:

class Model1 {
  getData() {
+   let start = new Date().valueOf()
+   try {
      // 此处省略获取数据的逻辑
      return [{
        id: 1,
        name: 'Niko'
      }, {
        id: 2,
        name: 'Bellic'
      }]
+   } finally {
+     let end = new Date().valueOf()
+     console.log(`start: ${start} end: ${end} consume: ${end - start}`)
+   }
  }
}

// start: XXX end: XXX consume: XXX
console.log(new Model1().getData())     // [ { id: 1, name: 'Niko'}, { id: 2, name: 'Bellic' } ]
// start: XXX end: XXX consume: XXX
console.log(Model1.prototype.getData()) // [ { id: 1, name: 'Niko'}, { id: 2, name: 'Bellic' } ]

这样在调用方法后我们就可以在控制台看到耗时的输出了。
但是这样直接修改原函数代码有以下几个问题:

  1. 统计耗时的相关代码与函数本身逻辑并无一点关系,影响到了对原函数本身的理解,对函数结构造成了破坏性的修改
  2. 如果后期还有更多类似的函数需要添加统计耗时的代码,在每个函数中都添加这样的代码显然是低效的,维护成本太高

所以,为了让统计耗时的逻辑变得更加灵活,我们将创建一个新的工具函数,用来包装需要设置统计耗时的函数。
通过将Class与目标函数的name传递到函数中,实现了通用的耗时统计:

function wrap(Model, key) {
  // 获取Class对应的原型
  let target = Model.prototype

  // 获取函数对应的描述符
  let descriptor = Object.getOwnPropertyDescriptor(target, key)

  // 生成新的函数,添加耗时统计逻辑
  let log = function (...arg) {
    let start = new Date().valueOf()
    try {
      return descriptor.value.apply(this, arg) // 调用之前的函数
    } finally {
      let end = new Date().valueOf()
      console.log(`start: ${start} end: ${end} consume: ${end - start}`)
    }
  }

  // 将修改后的函数重新定义到原型链上
  Object.defineProperty(target, key, {
    ...descriptor,
    value: log      // 覆盖描述符重的value
  })
}

wrap(Model1, 'getData')
wrap(Model2, 'getData')

// start: XXX end: XXX consume: XXX
console.log(new Model1().getData())     // [ { id: 1, name: 'Niko'}, { id: 2, name: 'Bellic' } ]
// start: XXX end: XXX consume: XXX
console.log(Model2.prototype.getData()) // [ { id: 1, name: 'Niko'}, { id: 2, name: 'Bellic' } ]

接下来,我们想控制其中一个Model的函数不可被其他人修改覆盖,所以要添加一些新的逻辑:

function wrap(Model, key) {
  // 获取Class对应的原型
  let target = Model.prototype

  // 获取函数对应的描述符
  let descriptor = Object.getOwnPropertyDescriptor(target, key)

  Object.defineProperty(target, key, {
    ...descriptor,
    writable: false      // 设置属性不可被修改
  })
}

wrap(Model1, 'getData')

Model1.prototype.getData = 1 // 无效

可以看出,两个wrap函数中有不少重复的地方,而修改程序行为的逻辑,实际上依赖的是Object.defineProperty中传递的三个参数。
所以,我们针对wrap在进行一次修改,将其变为一个通用类的转换:

function wrap(decorator) {
  return function (Model, key) {
    let target = Model.prototype
    let dscriptor = Object.getOwnPropertyDescriptor(target, key)

    decorator(target, key, descriptor)
  }
}

let log = function (target, key, descriptor) {
  // 将修改后的函数重新定义到原型链上
  Object.defineProperty(target, key, {
    ...descriptor,
    value: function (...arg) {
      let start = new Date().valueOf()
      try {
        return descriptor.value.apply(this, arg) // 调用之前的函数
      } finally {
        let end = new Date().valueOf()
        console.log(`start: ${start} end: ${end} consume: ${end - start}`)
      }
    }
  })
}

let seal = function (target, key, descriptor) {
  Object.defineProperty(target, key, {
    ...descriptor,
    writable: false
  })
}

// 参数的转换处理
log = wrap(log)
seal = warp(seal)

// 添加耗时统计
log(Model1, 'getData')
log(Model2, 'getData')

// 设置属性不可被修改
seal(Model1, 'getData')

到了这一步以后,我们就可以称logseal为装饰器了,可以很方便的让我们对一些函数添加行为。
而拆分出来的这些功能可以用于未来可能会有需要的地方,而不用重新开发一遍相同的逻辑。

Class 中的作用

就像上边提到了,现阶段在JS中继承多个Class是一件头疼的事情,没有直接的语法能够继承多个 Class。

class A { say () { return 1 } }
class B { hi () { return 2 } }
class C extends A, B {}        // Error
class C extends A extends B {} // Error

// 这样才是可以的
class C {}
for (let key of Object.getOwnPropertyNames(A.prototype)) {
  if (key === 'constructor') continue
  Object.defineProperty(C.prototype, key, Object.getOwnPropertyDescriptor(A.prototype, key))
}
for (let key of Object.getOwnPropertyNames(B.prototype)) {
  if (key === 'constructor') continue
  Object.defineProperty(C.prototype, key, Object.getOwnPropertyDescriptor(B.prototype, key))
}

let c = new C()
console.log(c.say(), c.hi()) // 1, 2

所以,在React中就有了一个mixin的概念,用来将多个Class的功能复制到一个新的Class上。
大致思路就是上边列出来的,但是这个mixinReact中内置的一个操作,我们可以将其转换为更接近装饰器的实现。
在不修改原Class的情况下,将其他Class的属性复制过来:

function mixin(constructor) {
  return function (...args) {
    for (let arg of args) {
      for (let key of Object.getOwnPropertyNames(arg.prototype)) {
        if (key === 'constructor') continue // 跳过构造函数
        Object.defineProperty(constructor.prototype, key, Object.getOwnPropertyDescriptor(arg.prototype, key))
      }
    }
  }
}

mixin(C)(A, B)

let c = new C()
console.log(c.say(), c.hi()) // 1, 2

以上,就是装饰器在函数、Class上的实现方法(至少目前是的),但是草案中还有一颗特别甜的语法糖,也就是@Decorator了。
能够帮你省去很多繁琐的步骤来用上装饰器。

@Decorator的使用方法

草案中的装饰器、或者可以说是TS实现的装饰器,将上边的两种进一步地封装,将其拆分成为更细的装饰器应用,目前支持以下几处使用:

  1. Class
  2. 函数
  3. get set访问器
  4. 实例属性、静态函数及属性
  5. 函数参数

@Decorator的语法规定比较简单,就是通过@符号后边跟一个装饰器函数的引用:

@tag
class A { 
  @method
  hi () {}
}

function tag(constructor) {
  console.log(constructor === A) // true
}

function method(target) {
  console.log(target.constructor === A, target === A.prototype) // true, true
}

函数tagmethod会在class A定义的时候执行。

@Decorator 在 Class 中的使用

该装饰器会在class定义前调用,如果函数有返回值,则会认为是一个新的构造函数来替代之前的构造函数。

函数接收一个参数:

  1. constructor 之前的构造函数

我们可以针对原有的构造函数进行一些改造:

新增一些属性

如果想要新增一些属性之类的,有两种方案可以选择:

  1. 创建一个新的class继承自原有class,并添加属性
  2. 针对当前class进行修改

后者的适用范围更窄一些,更接近mixin的处理方式。

@name
class Person {
  sayHi() {
    console.log(`My name is: ${this.name}`)
  }
}

// 创建一个继承自Person的匿名类
// 直接返回并替换原有的构造函数
function name(constructor) {
  return class extends constructor {
    name = 'Niko'
  }
}

new Person().sayHi()

修改原有属性的描述符

@seal
class Person {
  sayHi() {}
}

function seal(constructor) {
  let descriptor = Object.getOwnPropertyDescriptor(constructor.prototype, 'sayHi')
  Object.defineProperty(constructor.prototype, 'sayHi', {
    ...descriptor,
    writable: false
  })
}

Person.prototype.sayHi = 1 // 无效

使用闭包来增强装饰器的功能

在TS文档中被称为装饰器工厂

因为@符号后边跟的是一个函数的引用,所以对于mixin的实现,我们可以很轻易的使用闭包来实现:

class A { say() { return 1 } }
class B { hi() { return 2 } }

@mixin(A, B)
class C { }

function mixin(...args) {
  // 调用函数返回装饰器实际应用的函数
  return function(constructor) {
    for (let arg of args) {
      for (let key of Object.getOwnPropertyNames(arg.prototype)) {
        if (key === 'constructor') continue // 跳过构造函数
        Object.defineProperty(constructor.prototype, key, Object.getOwnPropertyDescriptor(arg.prototype, key))
      }
    }
  }
}

let c = new C()
console.log(c.say(), c.hi()) // 1, 2

多个装饰器的应用

装饰器是可以同时应用多个的(不然也就失去了最初的意义)。
用法如下:

@decorator1
@decorator2
class { }

执行的顺序为decorator2 -> decorator1,离class定义最近的先执行。
可以想像成函数嵌套的形式:

decorator1(decorator2(class {}))

@Decorator 在 Class 成员中的使用

类成员上的 @Decorator 应该是应用最为广泛的一处了,函数,属性,getset访问器,这几处都可以认为是类成员。
在TS文档中被分为了Method DecoratorAccessor DecoratorProperty Decorator,实际上如出一辙。

关于这类装饰器,会接收如下三个参数:

  1. 如果装饰器挂载于静态成员上,则会返回构造函数,如果挂载于实例成员上则会返回类的原型
  2. 装饰器挂载的成员名称
  3. 成员的描述符,也就是Object.getOwnPropertyDescriptor的返回值
Property Decorator不会返回第三个参数,但是可以自己手动获取
前提是静态成员,而非实例成员,因为装饰器都是运行在类创建时,而实例成员是在实例化一个类的时候才会执行的,所以没有办法获取对应的descriptor

静态成员与实例成员在返回值上的区别

可以稍微明确一下,静态成员与实例成员的区别:

class Model {
  // 实例成员
  method1 () {}
  method2 = () => {}

  // 静态成员
  static method3 () {}
  static method4 = () => {}
}

method1method2是实例成员,method1存在于prototype之上,而method2只在实例化对象以后才有。
作为静态成员的method3method4,两者的区别在于是否可枚举描述符的设置,所以可以简单地认为,上述代码转换为ES5版本后是这样子的:

function Model () {
  // 成员仅在实例化时赋值
  this.method2 = function () {}
}

// 成员被定义在原型链上
Object.defineProperty(Model.prototype, 'method1', {
  value: function () {}, 
  writable: true, 
  enumerable: false,  // 设置不可被枚举
  configurable: true
})

// 成员被定义在构造函数上,且是默认的可被枚举
Model.method4 = function () {}

// 成员被定义在构造函数上
Object.defineProperty(Model, 'method3', {
  value: function () {}, 
  writable: true, 
  enumerable: false,  // 设置不可被枚举
  configurable: true
})

可以看出,只有method2是在实例化时才赋值的,一个不存在的属性是不会有descriptor的,所以这就是为什么TS在针对Property Decorator不传递第三个参数的原因,至于为什么静态成员也没有传递descriptor,目前没有找到合理的解释,但是如果明确的要使用,是可以手动获取的。

就像上述的示例,我们针对四个成员都添加了装饰器以后,method1method2第一个参数就是Model.prototype,而method3method4的第一个参数就是Model

class Model {
  // 实例成员
  @instance
  method1 () {}
  @instance
  method2 = () => {}

  // 静态成员
  @static
  static method3 () {}
  @static
  static method4 = () => {}
}

function instance(target) {
  console.log(target.constructor === Model)
}

function static(target) {
  console.log(target === Model)
}

函数,访问器,和属性装饰器三者之间的区别

函数

首先是函数,函数装饰器的返回值会默认作为属性的value描述符存在,如果返回值为undefined则会忽略,使用之前的descriptor引用作为函数的描述符。
所以针对我们最开始的统计耗时的逻辑可以这么来做:

class Model {
  @log1
  getData1() {}
  @log2
  getData2() {}
}

// 方案一,返回新的value描述符
function log1(tag, name, descriptor) {
  return {
    ...descriptor,
    value(...args) {
      let start = new Date().valueOf()
      try {
        return descriptor.value.apply(this, args)
      } finally {
        let end = new Date().valueOf()
        console.log(`start: ${start} end: ${end} consume: ${end - start}`)
      }
    }
  }
}

// 方案二、修改现有描述符
function log2(tag, name, descriptor) {
  let func = descriptor.value // 先获取之前的函数

  // 修改对应的value
  descriptor.value = function (...args) {
    let start = new Date().valueOf()
    try {
      return func.apply(this, args)
    } finally {
      let end = new Date().valueOf()
      console.log(`start: ${start} end: ${end} consume: ${end - start}`)
    }
  }
}

访问器

访问器就是添加有getset前缀的函数,用于控制属性的赋值及取值操作,在使用上与函数没有什么区别,甚至在返回值的处理上也没有什么区别。
只不过我们需要按照规定设置对应的get或者set描述符罢了:

class Modal {
  _name = 'Niko'

  @prefix
  get name() { return this._name }
}

function prefix(target, name, descriptor) {
  return {
    ...descriptor,
    get () {
      return `wrap_${this._name}`
    }
  }
}

console.log(new Modal().name) // wrap_Niko

属性

对于属性的装饰器,是没有返回descriptor的,并且装饰器函数的返回值也会被忽略掉,如果我们想要修改某一个静态属性,则需要自己获取descriptor

class Modal {
  @prefix
  static name1 = 'Niko'
}

function prefix(target, name) {
  let descriptor = Object.getOwnPropertyDescriptor(target, name)

  Object.defineProperty(target, name, {
    ...descriptor,
    value: `wrap_${descriptor.value}`
  })
  
  return target
}

console.log(Modal.name1) // wrap_Niko

对于一个实例的属性,则没有直接修改的方案,不过我们可以结合着一些其他装饰器来曲线救国。

比如,我们有一个类,会传入姓名和年龄作为初始化的参数,然后我们要针对这两个参数设置对应的格式校验:

const validateConf = {} // 存储校验信息

@validator
class Person {
  @validate('string')
  name
  @validate('number')
  age

  constructor(name, age) {
    this.name = name
    this.age = age
  }
}

function validator(constructor) {
  return class extends constructor {
    constructor(...args) {
      super(...args)

      // 遍历所有的校验信息进行验证
      for (let [key, type] of Object.entries(validateConf)) {
        if (typeof this[key] !== type) throw new Error(`${key} must be ${type}`)
      }
    }
  }
}

function validate(type) {
  return function (target, name, descriptor) {
    // 向全局对象中传入要校验的属性名及类型
    validateConf[name] = type
  }
}

new Person('Niko', '18')  // throw new error: [age must be number]

首先,在类上边添加装饰器@validator,然后在需要校验的两个参数上添加@validate装饰器,两个装饰器用来向一个全局对象传入信息,来记录哪些属性是需要进行校验的。
然后在validator中继承原有的类对象,并在实例化之后遍历刚才设置的所有校验信息进行验证,如果发现有类型错误的,直接抛出异常。
这个类型验证的操作对于原Class来说几乎是无感知的。

函数参数装饰器

最后,还有一个用于函数参数的装饰器,这个装饰器也是像实例属性一样的,没有办法单独使用,毕竟函数是在运行时调用的,而无论是何种装饰器,都是在声明类时(可以认为是伪编译期)调用的。

函数参数装饰器会接收三个参数:

  1. 类似上述的操作,类的原型或者类的构造函数
  2. 参数所处的函数名称
  3. 参数在函数中形参中的位置(函数签名中的第几个参数)

一个简单的示例,我们可以结合着函数装饰器来完成对函数参数的类型转换:

const parseConf = {}
class Modal {
  @parseFunc
  addOne(@parse('number') num) {
    return num + 1
  }
}

// 在函数调用前执行格式化操作
function parseFunc (target, name, descriptor) {
  return {
    ...descriptor,
    value (...arg) {
      // 获取格式化配置
      for (let [index, type] of parseConf) {
        switch (type) {
          case 'number':  arg[index] = Number(arg[index])             break
          case 'string':  arg[index] = String(arg[index])             break
          case 'boolean': arg[index] = String(arg[index]) === 'true'  break
        }
      }

      return descriptor.value.apply(this, arg)
    }
  }
}

// 向全局对象中添加对应的格式化信息
function parse(type) {
  return function (target, name, index) {
    parseConf[index] = type
  }
}

console.log(new Modal().addOne('10')) // 11

使用装饰器实现一个有趣的Koa封装

比如在写Node接口时,可能是用的koa或者express,一般来说可能要处理很多的请求参数,有来自headers的,有来自body的,甚至有来自querycookie的。
所以很有可能在router的开头数行都是这样的操作:

router.get('/', async (ctx, next) => {
  let id = ctx.query.id
  let uid = ctx.cookies.get('uid')
  let device = ctx.header['device']
})

以及如果我们有大量的接口,可能就会有大量的router.getrouter.post
以及如果要针对模块进行分类,可能还会有大量的new Router的操作。

这些代码都是与业务逻辑本身无关的,所以我们应该尽可能的简化这些代码的占比,而使用装饰器就能够帮助我们达到这个目的。

装饰器的准备

// 首先,我们要创建几个用来存储信息的全局List
export const routerList      = []
export const controllerList  = []
export const parseList       = []
export const paramList       = []

// 虽说我们要有一个能够创建Router实例的装饰器
// 但是并不会直接去创建,而是在装饰器执行的时候进行一次注册
export function Router(basename = '') {
  return (constrcutor) => {
    routerList.push({
      constrcutor,
      basename
    })
  }
}

// 然后我们在创建对应的Get Post请求监听的装饰器
// 同样的,我们并不打算去修改他的任何属性,只是为了获取函数的引用
export function Method(type) {
  return (path) => (target, name, descriptor) => {
    controllerList.push({
      target,
      type,
      path,
      method: name,
      controller: descriptor.value
    })
  }
}

// 接下来我们还需要用来格式化参数的装饰器
export function Parse(type) {
  return (target, name, index) => {
    parseList.push({
      target,
      type,
      method: name,
      index
    })
  }
}

// 以及最后我们要处理的各种参数的获取
export function Param(position) {
  return (key) => (target, name, index) => {
    paramList.push({
      target,
      key,
      position,
      method: name,
      index
    })
  }
}

export const Body   = Param('body')
export const Header = Param('header')
export const Cookie = Param('cookie')
export const Query  = Param('query')
export const Get    = Method('get')
export const Post   = Method('post')

Koa服务的处理

上边是创建了所有需要用到的装饰器,但是也仅仅是把我们所需要的各种信息存了起来,而怎么利用这些装饰器则是下一步需要做的事情了:

const routers = []

// 遍历所有添加了装饰器的Class,并创建对应的Router对象
routerList.forEach(item => {
  let { basename, constrcutor } = item
  let router = new Router({
    prefix: basename
  })

  controllerList
    .filter(i => i.target === constrcutor.prototype)
    .forEach(controller => {
      router[controller.type](controller.path, async (ctx, next) => {
        let args = []
        // 获取当前函数对应的参数获取
        paramList
          .filter( param => param.target === constrcutor.prototype && param.method === controller.method )
          .map(param => {
            let { index, key } = param
            switch (param.position) {
              case 'body':    args[index] = ctx.request.body[key] break
              case 'header':  args[index] = ctx.headers[key]      break
              case 'cookie':  args[index] = ctx.cookies.get(key)  break
              case 'query':   args[index] = ctx.query[key]        break
            }
          })

        // 获取当前函数对应的参数格式化
        parseList
          .filter( parse => parse.target === constrcutor.prototype && parse.method === controller.method )
          .map(parse => {
            let { index } = parse
            switch (parse.type) {
              case 'number':  args[index] = Number(args[index])             break
              case 'string':  args[index] = String(args[index])             break
              case 'boolean': args[index] = String(args[index]) === 'true'  break
            }
          })

        // 调用实际的函数,处理业务逻辑
        let results = controller.controller(...args)

        ctx.body = results
      })
    })

  routers.push(router.routes())
})

const app = new Koa()

app.use(bodyParse())
app.use(compose(routers))

app.listen(12306, () => console.log('server run as http://127.0.0.1:12306'))

上边的代码就已经搭建出来了一个Koa的封装,以及包含了对各种装饰器的处理,接下来就是这些装饰器的实际应用了:

import { Router, Get, Query, Parse } from "../decorators"

@Router('')
export default class {
  @Get('/')
  index (@Parse('number') @Query('id') id: number) {
    return {
      code: 200,
      id,
      type: typeof id
    }
  }

  @Post('/detail')
  detail (
    @Parse('number') @Query('id') id: number, 
    @Parse('number') @Body('age') age: number
  ) {
    return {
      code: 200,
      age: age + 1
    }
  }
}

很轻易的就实现了一个router的创建,路径、method的处理,包括各种参数的获取,类型转换。
将各种非业务逻辑相关的代码统统交由装饰器来做,而函数本身只负责处理自身逻辑即可。
这里有完整的代码:GitHub。安装依赖后npm start即可看到效果。

这样开发带来的好处就是,让代码可读性变得更高,在函数中更专注的做自己应该做的事情。
而且装饰器本身如果名字起的足够好的好,也是在一定程度上可以当作文档注释来看待了(Java中有个类似的玩意儿叫做注解)。

总结

合理利用装饰器可以极大的提高开发效率,对一些非逻辑相关的代码进行封装提炼能够帮助我们快速完成重复性的工作,节省时间。
但是糖再好吃,也不要吃太多,容易坏牙齿的,同样的滥用装饰器也会使代码本身逻辑变得扑朔迷离,如果确定一段代码不会在其他地方用到,或者一个函数的核心逻辑就是这些代码,那么就没有必要将它取出来作为一个装饰器来存在。

参考资料

  1. typescript | decorators
  2. koa示例的原版,简化代码便于举例

One more thing

我司现在大量招人咯,前端、Node方向都有HC
公司名:Blued,坐标帝都朝阳双井
主要技术栈是React,也会有机会玩ReactNative和Electron
Node方向8.x版本+koa 新项目会以TS为主
有兴趣的小伙伴可以联系我详谈:
email: jiashunming@blued.com
wechat: github_jiasm

查看原文

赞 68 收藏 52 评论 14

fisecoco 关注了用户 · 2018-07-04

Python数据科学 @xiaoyusmd

微信公众号:Python数据科学

关注 1491

fisecoco 关注了用户 · 2018-07-04

守候 @chen93

一个web前端新手,正在web前端的路上打拼的小伙子。
在社区希望能和大家相互学习,相互鼓励,帮助。在这里我也把在工作中遇到的点滴,学习到的知识分享,希望能帮到大家,我也会不断的拼搏奋斗。
以后有所成就的我,一定会感谢现在努力奋斗的自己。

微信公众号:守候书阁 ,欢迎大家关注

在广州工作也几年了,什么时候能有个女朋友啊

关注 10999

fisecoco 关注了用户 · 2018-07-04

brianway @brianway

服务器端研发爱好者.
阅读最新博文请前往 brianway.github.io

关注 413

认证与成就

  • 获得 15 次点赞
  • 获得 1 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 1 枚铜徽章

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2016-09-18
个人主页被 144 人浏览