JELLY3.0 - 全栈开发的那些事

Jelly 是京东零售官方推出的设计共享平台,它是一个可以让设计师、开发者共同分享、交流、进步的平台,是同学们的经验能量补给站!目前3.0版本已经到来,拥有全新的视觉、交互体验,最重要的是支持全网用户使用啦,赶紧上车吧~

改版的起因

这次3.0升级改版我们将整个平台重新装修了一番,原因主要有:

  • 体验:页面整体交互视觉落后、交互体验繁杂。
  • 外网化:需要同时支持内外网用户访问。
  • 稳定性:当前底层技术架构难以满足将来的业务发展。

前端架构

3.0前端整体架构图如下:

JELLY3.0 - 全栈开发的那些事_第1张图片

Nerv

前端 MVVM 框架采用的是 Nerv,Nerv 是由 JDC·凹凸实验室 打造的类 React 前端框架。目前已广泛运用在京东商城核心业务及 TOPLIFE 全站。Nerv 基于 React 标准,使用 Virtual Dom 技术,拥有和 React 一致的 API 与生命周期,如果你已经对 React 使用非常熟悉,那么使用 Nerv 开发对你来说绝对是零学习成本。

日常开发中,相对于 Vue ,我们更倾向于选择 React 模式作为我们的开发标准,因为 React 天生组件化且函数式编程的方式,更加灵活且便于维护。
然而,React 仍然有一些不能满足我们需求的地方:

  • IE8 浏览器兼容性:当前环境所限,即便很不情愿,我们仍然要支持 IE8。
  • 体积:React 大概130 kb 的体积。在低网速 / 低版本浏览器 / 低配置设备的加载速度和解析速度都不能让我们满意。
  • 性能:React 的 Virtual Dom 算法(React 自己叫 Reconciler)并没有做太多的优化。

而我们的新轮子 —— Nerv,它完全能提供上述 React 的所有优点,并且它也能完全满足我们自己的需求:更好的兼容性、更小的体积、更高的性能。

Athena2

前端构建工具采用的是 Athena2,Athena2 是由 JDC·凹凸实验室 打造的前端自动化流程构建工具,提供多页、单页、H5 三种模板选择,支持 Nerv、React、Vue 三大框架编译,不仅包括了 csslint / jshint 代码检测、images 压缩、cssSprite 雪碧图、css / js 合并压缩等常用基本功能,还拥有独立的管理后台能够对资源进行实时监控,让你对项目情况一目了然!

JELLY3.0 - 全栈开发的那些事_第2张图片

代码规范

在项目中使用 Lint 是必不可少的,试想一下,你的项目正在被”五湖四海“的人共同维护,难不免有”广式烧腊、天津狗不理“各种口味,更难免的还有”臭味相投“的,这些情况严重影响了应用质量。

针对这种情况,我们在代码风格和组件编写顺序上制定了一套规范:

代码风格
  • 使用两个空格进行缩进
  • 字符串统一使用单引号
  • 代码块之间尽量使用仅且一行空行隔开
  • 模板字符串中变量前后不加空格
  • 样式块之间空行,包括嵌套
  • ClassName 命名基于姓氏命名法(继承 + 外来),如 mod_info,注意用“_”分割
  • Js 变量命名强制使用驼峰命名法
  • CSS 风格遵循 stylelint 插件的配置
  • 多注释
  • 优先函数式组件开发
组件编写顺序
  • static 开头的类属性,如 defaultProps、propTypes
  • 构造函数,constructor
  • getter / setter
  • 组件生命周期,按照生命周期调用顺序来书写
  • _ 开头的私有方法
  • 事件监听方法,handle*
  • render*开头的方法,有时候 render() 方法里面的内容会分开到不同函数里面进行,这些函数都以 render* 开头
  • render() 方法

但是在统一规范和落地 Lint 的过程中,往往会遇见以下这些痛点:

  • 代码规范落地难
  • 低质量代码带入线上
  • 代码格式难统一
  • 手动修复浪费时间
  • 非渐进式修复

针对以上这些痛点,我们决定采用 husky + lint-staged + eslint + prettier 方式来解决。

代码提交前自动修复和格式化

为了保证 Git 库中的代码都是符合规范的,我们必须在提交之前对新增的代码进行检查,如果有不规范的代码则进行修复,并且希望这整个过程都是自动完成的,不需要我们进行手动修复,并且对代码的修复是渐进式的,而不是全部推倒从来。

我们只需要在 packge.json 中简单配置下即可(已经装好了依赖):

  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "**/**/*.{ts,js,vue}": [
      "eslint --fix",
      "git add"
    ]
  },

如果你还不满足,还想在代码提交前让你的代码按照统一的格式整齐划一,你还可以在配置中加入 prettier

  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "**/**/*.{ts,js,vue}": [
      "prettier --write", // 自动格式化
      "eslint --fix",  // 自动修复
      "git add"
    ]
  },

你可以自定义 prettier 格式化的规范,使其与 Eslint 规范一致:

// .prettierrc
{
  "singleQuote": true,
  "semi": true,
  "bracketSpacing": true,
  "tabWidth": 2,
  "printWidth": 150,
  "useTabs": false,
  "htmlWhitespaceSensitivity": "strict"
}

视觉规范

JELLY 3.0 PC 端是需要兼容宽窄屏的,就是说需要在屏幕宽度小于1280和大于1280的时候展示两种不同的样式。

JELLY 3.0采用 rem + flexbox + 百分比实现响应式布局。针对这种情况,我们采用了 postcss-plugin-px2rem 插件自动计算 px 为 rem,PX 则不会自动计算为 rem。

值得注意的是,Stylelint 转换 CSS 格式时,会自动将 PX 转为 px,为了保留 postcss-plugin-px2rem 插件的逻辑,PX 单位一律使用 unitpx() 函数包裹。

/* 不需要转换为 rem */
function unitpx($n) {
  /* prettier-ignore */
  @return $n * 1PX;
}

采用 ACSS、ITCSS 方式配置全局的工具函数、原子类、视觉规范,比如字体大小、单行溢出、多行溢出、旋转动画、字号粗细、颜色、通用动画、宽窄版宽度、栅格间隔。

目录结构

3.0前端目录结构图如下:

JELLY3.0 - 全栈开发的那些事_第3张图片

项目结构遵循以下核心思想:

  • 以业务功能为单位组织项目结构;
  • 以低耦合度为目标划分模块职责和逻辑;

这样的思想能带来哪些优势?

  • 业务功能模块的相关代码都集中在一块,方便移动和删除;
  • 实现了关注点分离,方便开发、调试、维护、编写、查阅、理解代码;

根据核心思想我们将 src 划分为 assets、components、lib、reducers、actions 等目录,它们各自都有自己的业务功能,并且耦合度非常低。

组件结构
我们把组件分为页面组件和展示组件,页面组件放到 Views 目录中,而展示组件放到了 components 目录里。

页面组件以页面为单位,每个页面组件创建以组件名为名的独立目录,并且和路由相同,这样同一个业务功能对应一个页面组件和一个路由,这样可以很快的根据路由名称找到对应的组件,然后页面组件又根据页面上不同的子模块进行解耦,每个子模块以模块名称为名创建独立目录。

├── views                               # 项目的页面组件根目录
│   ├── HomeComing                     # 同学会页面组件根目录
│       ├── component                   # 同学会子模块根目录
│           ├── FlashClass              # 快闪课根目录
:
│           ├── Salon                   # 沙龙根目录
:
│       ├── HomeComing.js               # 同学会页面组件入口文件
│       ├── HomeComing.scss             # 同学会页面组件样式文件

这样划分后,目录是不是清晰了很多,再也不用担心找不到组件了!同样,reducer、actions、store、constants 等也都是按照上边的核心思想来划分的,使得整个项目的结构一目了然!

后端架构

3.0后端整体架构图如下:

JELLY3.0 - 全栈开发的那些事_第4张图片

我们使用了 Koa2 快速搭建后端服务,async / await 在语义化上要比 callback / generator / promise 更强,配合 Koa2 生态的各种插件和我们自己实现的各种中间件,完全能满足各种需求。

数据库我们采用了 MongoDB,它的高性能、易部署、易使用是我们选择它的原因,基于文档的灵活的设计模式也是它的一大优势,实现增删改查非常方便,搭配数据库可视化工具 Navicat Premium 真的香爆了!

接口入参校验

我们在实现一些功能时,往往需要对接口请求参数进行验证,来保证接口请求符合要求。针对这种情况,Joi 刚好可以帮我们完成。

以下是搜索接口的代码片段:

  validate: {
    query: {
      id: Joi.string()
        .trim()
        .allow('')
        .description('搜索id'),
      skip: Joi.number()
        .allow('')
        .description('忽略个数'),
      keyword: Joi.string()
        .trim()
        .description('搜索关键字'),
    },
  },

这里使用 Joi 验证了 id、skip、keyword 等字段的数据,当后端接收到搜索接口请求的时候,会对这几个参数进行验证,不符合规定则会请求失败,这样就完成了对接口入参的校验。

定时任务

node-schedule 是 Node.js 的一个 定时任务(crontab)模块。我们可以使用定时任务来对服务器系统进行维护,让其在固定的时间段执行某些必要的操作,还可以使用定时任务发送邮件、更新热门稿件等;

这里我们使用 Cron 风格来指定定时任务,在这之前需要先了解 Cron 格式参数:

 *    *    *    *    *    *
 ┬    ┬    ┬    ┬    ┬    ┬
 │    │    │    │    │    │
 │    │    │    │    │    └ day of week (0 - 7) (0 or 7 is Sun)
 │    │    │    │    └───── month (1 - 12)
 │    │    │    └────────── day of month (1 - 31)
 │    │    └─────────────── hour (0 - 23)
 │    └──────────────────── minute (0 - 59)
 └───────────────────────── second (0 - 59, OPTIONAL)

6个占位符从左到右分别代表:秒、分、时、日、月、周几,*表示通配符,匹配任意,当秒是*时,表示任意秒数都触发,其他的以此类推。

定时发送邮件

假设我们需要每天上午10点发送将要开始的同学会通知邮件:

schedule.scheduleJob('0 0 10 * * *', async function () {
  Logger.info('【schedule start】同学会邮件提醒')
  await mailNotice({
    send_time_type: 'timing', // 邮件发送时间类型,timing表示定时发送
  })
  Logger.info('【schedule done】同学会邮件提醒')
})
定时更新热门稿件

假设我们需要每月更新热门稿件:

schedule.scheduleJob('0 0 0 1 * *', async function () {
  Logger.info('【schedule start】项目稿件月度热门')
  await updateMonthHits()
  Logger.info('【schedule done】项目稿件月度热门')
})

由此可见,如果我们想在后端里指定一些定时任务时,使用 node-shedule 是非常方便的~

权限管理

Jelly 3.0的权限管理是基于 RBAC 的思想设计的,它拥有4个关键元素:用户 – 角色 – 权限 – 资源

  • 资源:被安全管理的对象(页面、菜单、按钮等)
  • 权限:访问和操作资源的许可(删除、编辑、审批等)
  • 角色:我们通过把权限给这个角色,再把角色给用户,从而实现用户的权限,因此它承担了一个桥梁的作用。
  • 用户:系统实际的操作员(User)

在 RBAC 中,权限与角色相关联,用户通过成为角色的成员而得到这些角色的权限。这就极大地简化了权限的管理。

以下是 Jelly 中的角色栏:

JELLY3.0 - 全栈开发的那些事_第5张图片

角色是为了完成各种工作而创造,用户则依据它的责任和资格来被指派相应的角色,用户可以很容易地从一个角色被指派到另一个角色而赋予新的权限,而权限也可根据需要而从某角色中回收。

以下是 JELLY 基于 RBAC 的 Roles & Permissions 中间件的部分代码:

const { default: RBAC, Mongoose } = require('rbac')

const rbac = new RBAC({
  storage: new Mongoose({
    connection,
    Schema: connection.base.Schema,
  }),
})

/**
 * 初始化权限数据
 */
async function createRBAC () {
  // Drop rbac database
  await new Promise((resolve, reject) => {
    rbac.storage._model.collection.drop(err => {
      if (err && err.message !== 'ns not found') reject(err)
      resolve()
    })
  })

  const { Roles, Permissions, Grants } = require('../lib/constants')
  return new Promise((resolve, reject) => {
    rbac.create(map(Roles, 'key'), Permissions, Grants, function (err, data) {
      if (err) return reject(err)
      resolve()
    })
  })
}

项目优化

图片裁剪压缩

由于 JELLY 3.0 页面上有非常多的卡片,这些卡片承载的是封面图,虽然我们使用了懒加载,但是封面图尺寸还是太大,造成资源浪费、加载缓慢的问题。

针对这种情况,我们将封面图进行了裁剪、转换 Webp,提升图片加载速度,减少资源浪费,我们使用了内部 CDN 提供的图片裁剪、压缩、转换 Webp 功能,我们只需要把原始图片的 url 替换一下即可,以下是 url 替换的部分源码:

export function getImg (url, width, height, options) {
  url = url.replace(/m\.360buyimg\.com/i, 'img10.360buyimg.com') // m.360buyimg.com 替换
  var rUrl = url.match(/(\S*(jpg|jpeg|png|webp|gif))\s*/g)
  if (!rUrl) return url
  url = rUrl[0] // 去除图片后缀的多余字符
  
  // 设定宽高
  if (width > 0) {
    width = Math.floor(width)
    height = Math.floor(height)
    url = url.replace(
      /(\/)(?:s\d+x\d+_)?(jfs\/)/,
      '$1s'.concat(width, 'x').concat(height, '_$2')
    )
    url += '!cc_'.concat(width, 'x').concat(height) // 转webp
  } 

  // 压缩质量
  if (quality) {
    url = url.replace(/(\.(jpg|jpeg))(!q\d{1,2})?/, '$1!q'.concat(quality))
  } 

  var pool = [10, 11, 12, 13, 14, 20, 30] // 域名池, 分散域名

  var idx =
    (parseInt(url.substr(url.lastIndexOf('/') + 1, 8), 36) || 0) % pool.length
  url = url.replace(
    /(\/\/img)\d{1,2}(\.360buyimg\.com)/,
    '$1'.concat(pool[idx], '$2')
  )
  return url
}

拿一个图片来举例:

const cover = 'http://img10.360buyimg.com/ling/jfs/t1/11801/14/12271/78918/5c8f620dE35b2ee1a/6ae92ca018000696.png'

这是一张初始图片,我们将这张图片进行裁剪、转换 Webp:

const img = getImg(cover, 100, 50) 
// http://img10.360buyimg.com/ling/jfs/t1/11801/14/12271/78918/5c8f620dE35b2ee1a/6ae92ca018000696.png!cc_100x50.webp

通过裁剪、转换 Webp,这张图片大小从80 KB 减少到了18 KB,这样图片质量不会被压缩,也能减少资源浪费,页面上的卡片加载速度大大提升。

平滑滚动

对于页面平滑滚动的需求,经常在返回顶部、锚点跳转的时候用到,可能大家会使用 JS 去实现这种需求,比如这样:

/**
 @description 页面垂直平滑滚动到指定滚动高度
*/
const scrollSmoothTo = function(position) {
  if (isIE()) {
    window.scrollTo(0, position)
  } else {
    window.scrollTo({
      top: position,
      behavior: 'smooth'
    })
  }
}

但是采用这种方式,需要在每个需要实现这种效果的地方都去执行这个方法。

针对这种情况,其实一行代码就能实现,而且是全局生效的:

html,
body {
  scroll-behavior: smooth;
}

虽然这招屡试不爽,但是对 IE 、Safari 浏览器兼容性不好:

JELLY3.0 - 全栈开发的那些事_第6张图片

粘性定位

JELLY 中专业课程左侧的目录、经验沉淀文章右侧的点赞收藏等都需要在页面滚动后固定在那个位置,我们可能会使用 fixed 定位来实现,但是 fixed 定位会出现一个问题:假如目录很长,当页面滚动到底部时,会遮挡页面 Footer:

JELLY3.0 - 全栈开发的那些事_第7张图片

针对这种情况,我们使用了 sticky 粘性定位,基本上,sticky 粘性定位可以看成是 position:relativeposition:fixed 的结合体——当元素在屏幕内,表现为 relative,就要滚出显示器屏幕的时候,表现为 fixed。

先看下效果:

实现也很简单,只需要把定位设置为 sticky 即可:

.articleListSideBar {
    position: sticky;
    top: 100PX;
    width: 275PX;
}

另外,利用 sticky 粘性定位可以很方便的实现吸顶栏(一般人我不告诉他)~

结语

JELLY 升级到 3.0 版本共经历了2个月的时间,在这两个月的日日夜夜里,我们与 JELLY 共同成长,一起经历挑战,最后的收获也是满满的,但 JELLY 的成长之路还远远没有结束,未来仍旧充满挑战,在未来的岁月中,我们会不断提升用户体验,提升我们自己的技术水平,我们会以京东主站的性能要求自己,争取 JELLY 能够让每一位用户满意。

最后,感谢给我们提出建议和反馈的小伙伴们~

你可能感兴趣的