阿宝哥

阿宝哥 查看完整档案

厦门编辑集美大学  |  自动化 编辑  |  填写所在公司/组织 www.semlinker.com 编辑
编辑

http://www.semlinker.com/
聚焦全栈,专注分享 Angular、TypeScript、Node.js/Java 、Spring 技术栈等全栈干货

欢迎各位小伙伴关注本人公众号全栈修仙之路

个人动态

阿宝哥 赞了文章 · 3月22日

探索 Snabbdom 模块系统原理

snabbdom-cover

近几年随着 React、Vue 等前端框架不断兴起,Virtual DOM 概念也越来越火,被用到越来越多的框架、库中。Virtual DOM 是基于真实 DOM 的一层抽象,用简单的 JS 对象描述真实 DOM。本文要介绍的 Snabbdom 就是 Virtual DOM 的一种简单实现,并且 Vue 的 Virtual DOM 也参考了 Snabbdom 实现方式。

对于想要深入学习 Vue Virtual DOM 的朋友,建议先学习 Snabbdom,对理解 Vue 会很有帮助,并且其核心代码 200 多行。

本文挑选 Snabbdom 模块系统作为主要核心点介绍,其他内容可以查阅官方文档《Snabbdom》

snabbdom-cover

一、Snabbdom 是什么

Snabbdom 是一个专注于简单性、模块化、强大特性和性能的虚拟 DOM 库。其中有几个核心特性:

  1. 核心代码 200 行,并且提供丰富的测试用例;
  2. 拥有强大模块系统,并且支持模块拓展和灵活组合;
  3. 在每个 VNode 和全局模块上,都有丰富的钩子,可以在 Diff 和 Patch 阶段使用。

接下来从一个简单示例来体验一下 Snabbdom。

1. 快速上手

安装 Snabbdom:

npm install snabbdom -D

接着新建 index.html,设置入口元素:

<div id="app"></div>

然后新建 demo1.js 文件,并使用 Snabbdom 提供的函数:

// demo1.js
import { h } from 'snabbdom/src/package/h'
import { init } from 'snabbdom/src/package/init'

const patch = init([])
let vnode = h('div#app', 'Hello Leo')
const app = document.getElementById('app')
patch(app, vnode)

这样就实现一个简单示例,在浏览器打开 index.html,页面将显示 “Hello Leo” 文本。
img-1.png

接下来,我会以 snabbdom-demo 项目作为学习示例,从简单示例到模块系统使用的示例,深入学习和分析 Snabbdom 源码,重点分析 Snabbdom 模块系统。

二、Snabbdom-demo 分析

Snabbdom-demo 项目中的三个演示代码,为我们展示如何从简单到深入 Snabbdom。
首先克隆仓库并安装:

$ git clone https://github.com/zyycode/snabbdom-demo.git
$ npm install

虽然本项目没有 README.md 文件,但项目目录比较直观,我们可以轻松的从 src 目录找到这三个示例代码的文件:

  • 01-basicusage.js
  • 02-basicusage.js
  • 03-modules.js -> 本文核心介绍

接着在 index.html 中引入想要学习的代码文件,默认 <script data-original="./src/01-basicusage.js"></script>  ,通过 package.json 可知启动命令并启动项目:

$ npm run dev

1. 简单示例分析

当我们要研究一个库或框架等比较复杂的项目,可以通过官方提供的简单示例代码进行分析,我们这里选择该项目中最简单的 01-basicusage.js 代码进行分析,其代码如下:

// src/01-basicusage.js

import { h } from 'snabbdom/src/package/h'
import { init } from 'snabbdom/src/package/init'

const patch = init([])

let vnode = h('div#container.cls', 'Hello World')
const app = document.getElementById('app') // 入口元素

const oldVNode = patch(app, vnode)

// 假设时刻
vnode = h('div', 'Hello Snabbdom')
patch(oldVNode, vnode)

运行项目以后,可以看到页面展示了“Hello Snabbdom”文本,这里你会觉得奇怪,前面的 “Hello World” 文本去哪了

img-2.png

原因很简单,我们把 demo 中的下面两行代码注释后,页面便显示文本是 “Hello World”:

vnode = h('div', 'Hello Snabbdom')
patch(oldVNode, vnode)

这里我们可以猜测 patch() 函数可以将 VNode 渲染到页面。更进一步可以理解为,这边第一个执行 patch() 函数为首次渲染,第二次执行 patch() 函数为更新操作

img-3.png

2. VNode 介绍

这里可能会有小伙伴疑惑,示例中的 VNode 是什么?这里简单解释下:

VNode,该对象用于描述节点的信息,它的全称是虚拟节点(virtual node)。与 “虚拟节点” 相关联的另一个概念是 “虚拟 DOM”,它是我们对由 Vue 组件树建立起来的整个 VNode 树的称呼。“虚拟 DOM” 由 VNode 组成的。
—— 全栈修仙之路 《Vue 3.0 进阶之 VNode 探秘》

其实 VNode 就是一个 JS 对象,在 Snabbdom 中是这么定义 VNode 的类型:

export interface VNode {
  sel: string | undefined; // selector的缩写
  data: VNodeData | undefined; // 下面VNodeData接口的内容
  children: Array<VNode | string> | undefined; // 子节点
  elm: Node | undefined; // element的缩写,存储了真实的HTMLElement
  text: string | undefined; // 如果是文本节点,则存储text
  key: Key | undefined; // 节点的key,在做列表时很有用
}

export interface VNodeData {
  props?: Props
  attrs?: Attrs
  class?: Classes
  style?: VNodeStyle
  dataset?: Dataset
  on?: On
  hero?: Hero
  attachData?: AttachData
  hook?: Hooks
  key?: Key
  ns?: string // for SVGs
  fn?: () => VNode // for thunks
  args?: any[] // for thunks
  [key: string]: any // for any other 3rd party module
}

在 VNode 对象中含描述节点选择器 sel 字段、节点数据 data 字段、节点所包含的子节点 children 字段等。

在这个 demo 中,我们似乎并没有看到模块系统相关的代码,没事,因为这是最简单的示例,下一节会详细介绍。

我们在学习一个函数时,可以重点了解该函数的“入参”和“出参”,大致就能判断该函数的作用。

从这个 demo 主要执行过程可以看出,主要用到有三个函数: init() / patch() / h() ,它们到底做什么用的呢?我们分析一下 Snabbdom 源码中这三个函数的入参和出参情况:

3. init() 函数分析

init() 函数被定义在 package/init.ts 文件中:

// node_modules/snabbdom/src/package/init.ts

export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
    // 省略其他代码
}

其参数类型如下:

function init(modules: Array<Partial<Module>>, domApi?: DOMAPI): (oldVnode: VNode | Element, vnode: VNode) => VNode

export type Module = Partial<{
  pre: PreHook
  create: CreateHook
  update: UpdateHook
  destroy: DestroyHook
  remove: RemoveHook
  post: PostHook
}>
  
export interface DOMAPI {
  createElement: (tagName: any) => HTMLElement
  createElementNS: (namespaceURI: string, qualifiedName: string) => Element
  createTextNode: (text: string) => Text
  createComment: (text: string) => Comment
  insertBefore: (parentNode: Node, newNode: Node, referenceNode: Node | null) => void
  removeChild: (node: Node, child: Node) => void
  appendChild: (node: Node, child: Node) => void
  parentNode: (node: Node) => Node | null
  nextSibling: (node: Node) => Node | null
  tagName: (elm: Element) => string
  setTextContent: (node: Node, text: string | null) => void
  getTextContent: (node: Node) => string | null
  isElement: (node: Node) => node is Element
  isText: (node: Node) => node is Text
  isComment: (node: Node) => node is Comment
}

init() 函数接收一个模块数组 modules 和可选的 domApi 对象作为参数,返回一个函数,即 patch() 函数。
domApi 对象的接口包含了很多 DOM 操作的方法。
这里的 modules 参数本文将重点介绍。

4. patch() 函数分析

init() 函数返回了一个 patch() 函数,其类型为:

// node_modules/snabbdom/src/package/init.ts

patch(oldVnode: VNode | Element, vnode: VNode) => VNode

patch() 函数接收两个 VNode 对象作为参数,并返回一个新 VNode。

5. h() 函数分析

h() 函数被定义在 package/h.ts 文件中:

// node_modules/snabbdom/src/package/h.ts

export function h(sel: string): VNode
export function h(sel: string, data: VNodeData | null): VNode
export function h(sel: string, children: VNodeChildren): VNode
export function h(sel: string, data: VNodeData | null, children: VNodeChildren): VNode
export function h (sel: any, b?: any, c?: any): VNode{
    // 省略其他代码
}

h() 函数接收多种参数,其中必须有一个 sel 参数,作用是将节点内容挂载到该容器中,并返回一个新 VNode。

6. 小结

通过前面介绍,我们在回过头看看这个 demo 的代码,大致调用流程如下:

img-4.png

三、深入 Snabbdom 模块系统

学习完前面这些基础知识后,我们已经知道 Snabbdom 使用方式,并且知道其中三个核心方法入参出参情况和大致作用,接下来开始看本文核心 Snabbdom 模块系统。

1. Modules 介绍

Snabbdom 模块系统是 Snabbdom 提供的一套可拓展可灵活组合的模块系统,用来为 Snabbdom 提供操作 VNode 时的各种模块支持,如我们组建需要处理 style 则引入对应的 styleModule,需要处理事件,则引入 eventListenersModule 既可,这样就达到灵活组合,可以支持按需引入的效果。

Snabbdom 模块系统的特点可以概括为:支持按需引入、独立管理、职责单一、方便组合复用、可维护性强。

当然 Snabbdom 模块系统还有其他内置模块:

模块名称模块功能示例代码
attributesModule为 DOM 元素设置属性,在属性添加和更新时使用 setAttribute 方法。h('a', { attrs: { href: '/foo' } }, 'Go to Foo')
classModule用来动态设置和切换 DOM 元素上的 class 名称。h('a', { class: { active: true, selected: false } }, 'Toggle')
datasetModule为 DOM 元素设置自定义数据属性(data- *)。然后可以使用 HTMLElement.dataset 属性访问它们。h('button', { dataset: { action: 'reset' } }, 'Reset')
eventListenersModule为 DOM 元素绑定事件监听器。h('div', { on: { click: clickHandler } })
propsModule为 DOM 元素设置属性,如果同时使用 attributesModule,则会被 attributesModule 覆盖。h('a', { props: { href: '/foo' } }, 'Go to Foo')
styleModule为 DOM 元素设置 CSS 属性。h('span', {style: { color: '#c0ffee'}}, 'Say my name')

2. Hooks 介绍

Hooks 也称钩子,是 DOM 节点生命周期的一种方法。Snabbdom 提供丰富的钩子选择。模块既使用钩子来扩展 Snabbdom,也在普通代码中使用钩子,用来在 DOM 节点生命周期中执行任意代码。

这里大致介绍一下所有的 Hooks:

钩子名称触发时机回调参数
prepatch 阶段开始。none
init已添加一个 VNode。vnode
create基于 VNode 创建了一个 DOM 元素。emptyVnode, vnode
insert一个元素已添加到 DOM 元素中。vnode
prepatch一个元素即将进入 patch 阶段。oldVnode, vnode
update一个元素开始更新。oldVnode, vnode
postpatch一个元素完成 patch 阶段。oldVnode, vnode
destroy一个元素直接或间接被删除。vnode
remove一个元素直接从 DOM 元素中删除。vnode, removeCallback
postpatch 阶段结束。none

模块中可以使用这些钩子:precreateupdatedestroyremovepost
单个元素可以使用这些钩子:initcreateinsertprepatchupdatepostpatchdestroyremove

Snabbdom 是这么定义钩子的:

// snabbdom/src/package/hooks.ts

export type PreHook = () => any
export type InitHook = (vNode: VNode) => any
export type CreateHook = (emptyVNode: VNode, vNode: VNode) => any
export type InsertHook = (vNode: VNode) => any
export type PrePatchHook = (oldVNode: VNode, vNode: VNode) => any
export type UpdateHook = (oldVNode: VNode, vNode: VNode) => any
export type PostPatchHook = (oldVNode: VNode, vNode: VNode) => any
export type DestroyHook = (vNode: VNode) => any
export type RemoveHook = (vNode: VNode, removeCallback: () => void) => any
export type PostHook = () => any

export interface Hooks {
  pre?: PreHook
  init?: InitHook
  create?: CreateHook
  insert?: InsertHook
  prepatch?: PrePatchHook
  update?: UpdateHook
  postpatch?: PostPatchHook
  destroy?: DestroyHook
  remove?: RemoveHook
  post?: PostHook
}

接下来我们通过 03-modules.js 文件的示例代码,我们需要样式处理事件操作,因此引入这两个模块,并进行灵活组合

// src/03-modules.js

import { h } from 'snabbdom/src/package/h'
import { init } from 'snabbdom/src/package/init'

// 1. 导入模块
import { styleModule } from 'snabbdom/src/package/modules/style'
import { eventListenersModule } from 'snabbdom/src/package/modules/eventlisteners'

// 2. 注册模块
const patch = init([ styleModule, eventListenersModule ])

// 3. 使用 h() 函数的第二个参数传入模块需要的数据(对象)
let vnode = h('div', {
  style: { backgroundColor: '#4fc08d', color: '#35495d' },
  on: { click: eventHandler }
}, [
  h('h1', 'Hello Snabbdom'),
  h('p', 'This is p tag')
])

function eventHandler() {
  console.log('clicked.')
}

const app = document.getElementById('app')
patch(app, vnode)

上面代码中,引入了 styleModule 和 eventListenersModule 两个模块,并且作为参数组合,传入 init() 函数中。
此时我们可以看到页面上显示的内容已经有包含样式,并且点击事件也能正常输出日志 'clicked.'

img-5.png

这里我们看下 styleModule 模块源码,把代码精简一下:

// snabbdom/src/package/modules/style.ts

function updateStyle (oldVnode: VNode, vnode: VNode): void {
    // 省略其他代码
}

function forceReflow () {
  // 省略其他代码
}

function applyDestroyStyle (vnode: VNode): void {
  // 省略其他代码
}

function applyRemoveStyle (vnode: VNode, rm: () => void): void {
  // 省略其他代码
}

export const styleModule: Module = {
  pre: forceReflow,
  create: updateStyle,
  update: updateStyle,
  destroy: applyDestroyStyle,
  remove: applyRemoveStyle
}

在看看 eventListenersModule 模块源码:

// snabbdom/src/package/modules/eventlisteners.ts

function updateEventListeners (oldVnode: VNode, vnode?: VNode): void {
    // 省略其他代码
}

export const eventListenersModule: Module = {
  create: updateEventListeners,
  update: updateEventListeners,
  destroy: updateEventListeners
}

明显可以看出,两个模块返回的都是个对象,并且每个属性为一种钩子,如 pre/create 等,值为对应的处理函数,每个处理函数有统一的入参。

继续看下 styleModule 中,样式是如何绑定上去的。这里分析它的 updateStyle 方法,因为元素创建(create 钩子)和元素更新(update 钩子)阶段都是通过这个方法处理:

// snabbdom/src/package/modules/style.ts

function updateStyle (oldVnode: VNode, vnode: VNode): void {
  var cur: any
  var name: string
  var elm = vnode.elm
  var oldStyle = (oldVnode.data as VNodeData).style
  var style = (vnode.data as VNodeData).style

  if (!oldStyle && !style) return
  if (oldStyle === style) return
  
  // 1. 设置新旧 style 默认值
  oldStyle = oldStyle || {}
  style = style || {}
  var oldHasDel = 'delayed' in oldStyle

  // 2. 比较新旧 style
  for (name in oldStyle) {
    if (!style[name]) {
      if (name[0] === '-' && name[1] === '-') {
        (elm as any).style.removeProperty(name)
      } else {
        (elm as any).style[name] = ''
      }
    }
  }
  for (name in style) {
    cur = style[name]
    if (name === 'delayed' && style.delayed) {
      // 省略部分代码
    } else if (name !== 'remove' && cur !== oldStyle[name]) {
      if (name[0] === '-' && name[1] === '-') {
        (elm as any).style.setProperty(name, cur)
      } else {
        // 3. 设置新 style 到元素
        (elm as any).style[name] = cur
      }
    }
  }
}

3. init() 分析

接着我们看下 init() 函数内部如何处理这些 Module。

首先在 init.ts 文件中,可以看到声明了默认支持的 Hooks 钩子列表:

// snabbdom/src/package/init.ts

const hooks: Array<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post']

接着看 hooks 是如何使用的:

// snabbdom/src/package/init.ts

export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
  let i: number
  let j: number
  const cbs: ModuleHooks = {  // 创建 cbs 对象,用于收集 module 中的 hook
    create: [],
    update: [],
    remove: [],
    destroy: [],
    pre: [],
    post: []
  }
    // 收集 module 中的 hook,并保存在 cbs 中
  for (i = 0; i < hooks.length; ++i) {
    cbs[hooks[i]] = []
    for (j = 0; j < modules.length; ++j) {
      const hook = modules[j][hooks[i]]
      if (hook !== undefined) {
        (cbs[hooks[i]] as any[]).push(hook)
      }
    }
  }
    // 省略其他代码,稍后介绍
}

上面代码中,创建 hooks 变量用来声明默认支持的 Hooks 钩子,在 init() 函数中,创建 cbs 对象,通过两层循环,保存每个 module 中的 hook 函数到 cbs 对象的指定钩子中。

通过断点可以看到这是 demo 中,cbs 对象是下面这个样子:

img-6.png

这里 cbs 对象收集了每个 module 中的 Hooks 处理函数,保存到对应 Hooks 数组中。比如这里的 create 钩子中保存了 updateStyle 函数和 updateEventListeners 函数。

img-7.png

到这里, init() 函数已经保存好所有 module 的 Hooks 处理函数,接下来就要看看 init() 函数返回的 patch() 函数,这里面将用到前面保存好的 cbs 对象。

4. patch() 分析

init() 函数中最终返回一个 patch() 函数,这边形成一个闭包,闭包里面可以使用到 init() 函数作用域定义的变量和方法,因此在 patch() 函数中能使用 cbs 对象。

patch() 函数会在不同时机点(可以参照前面的 Hooks 介绍),遍历 cbs 对象中不同 Hooks 处理函数列表。

// snabbdom/src/package/init.ts

export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
    // 省略其他代码
  return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
    let i: number, elm: Node, parent: Node
    const insertedVnodeQueue: VNodeQueue = []
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()  // [Hooks]遍历 pre Hooks 处理函数列表

    if (!isVnode(oldVnode)) {
      oldVnode = emptyNodeAt(oldVnode) // 当 oldVnode 参数不是 VNode 则创建一个空的 VNode
    }

    if (sameVnode(oldVnode, vnode)) {  // 当两个 VNode 为同一个 VNode,则进行比较和更新
      patchVnode(oldVnode, vnode, insertedVnodeQueue)
    } else {
      createElm(vnode, insertedVnodeQueue) // 当两个 VNode 不同,则创建新元素

      if (parent !== null) {  // 当该 oldVnode 有父节点,则插入该节点,然后移除原来节点
        api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
        removeVnodes(parent, [oldVnode], 0, 0)
      }
    }
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()  // [Hooks]遍历 post Hooks 处理函数列表
    return vnode
  }
}

patchVnode() 函数定义如下:

  function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
    // 省略其他代码
    if (vnode.data !== undefined) {
      for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)  // [Hooks]遍历 update Hooks 处理函数列表
    }
  }

createVnode() 函数定义如下:

  function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
    // 省略其他代码
    const sel = vnode.sel
    if (sel === '!') {
      // 省略其他代码
    } else if (sel !== undefined) {
      for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode)  // [Hooks]遍历 create Hooks 处理函数列表
      const hook = vnode.data!.hook
    }
    return vnode.elm
  }

removeNodes() 函数定义如下:

  function removeVnodes (parentElm: Node,vnodes: VNode[],startIdx: number,endIdx: number): void {
    // 省略其他代码
    for (; startIdx <= endIdx; ++startIdx) {
      const ch = vnodes[startIdx]
      if (ch != null) {
        rm = createRmCb(ch.elm!, listeners)
        for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm) // [Hooks]遍历 remove Hooks 处理函数列表
      }
    }
  }

这部分代码跳转较多,总结一下这个过程,如下图:

img-8.png

四、自定义 Snabbdom 模块

前面我们介绍了 Snabbdom 模块系统是如何收集 Hooks 并保存下来,然后在不同时机点执行不同的 Hooks。

在 Snabbdom 中,所有模块独立在 src/package/modules 下,使用的时候可以灵活组合,也方便做解耦和跨平台,并且所有 Module 返回的对象中每个 Hooks 类型如下:

// snabbdom/src/package/init.ts

export type Module = Partial<{
  pre: PreHook
  create: CreateHook
  update: UpdateHook
  destroy: DestroyHook
  remove: RemoveHook
  post: PostHook
}>

// snabbdom/src/package/hooks.ts
export type PreHook = () => any
export type CreateHook = (emptyVNode: VNode, vNode: VNode) => any
export type UpdateHook = (oldVNode: VNode, vNode: VNode) => any
export type DestroyHook = (vNode: VNode) => any
export type RemoveHook = (vNode: VNode, removeCallback: () => void) => any
export type PostHook = () => any

因此,如果开发者需要自定义模块,只需实现不同 Hooks 并导出即可。

接下来我们实现一个简单的模块 replaceTagModule,用来将节点文本自动过滤掉 HTML 标签

1. 初始化代码

考虑到方便调试,我们直接在 node_modules/snabbdom/src/package/modules/ 目录中新建 replaceTag.ts 文件,然后写个最简单的 demo 框架:

import { VNode, VNodeData } from '../vnode'
import { Module } from './module'

const replaceTagPre = () => {
    console.log("run replaceTagPre!")
}

const updateReplaceTag = (oldVnode: VNode, vnode: VNode): void => {
    console.log("run updateReplaceTag!", oldVnode, vnode)
}

const removeReplaceTag = (vnode: VNode): void => {
    console.log("run removeReplaceTag!", vnode)
}

export const replaceTagModule: Module = {
    pre: replaceTagPre,
    create: updateReplaceTag,
    update: updateReplaceTag,
    remove: removeReplaceTag
}

接下来引入到 03-modules.js 代码中,并简化下代码:

import { h } from 'snabbdom/src/package/h'
import { init } from 'snabbdom/src/package/init'

// 1. 导入模块
import { styleModule } from 'snabbdom/src/package/modules/style'
import { eventListenersModule } from 'snabbdom/src/package/modules/eventlisteners'
import { replaceTagModule } from 'snabbdom/src/package/modules/replaceTag';

// 2. 注册模块
const patch = init([
  styleModule,
  eventListenersModule,
  replaceTagModule
])

// 3. 使用 h() 函数的第二个参数传入模块需要的数据(对象)
let vnode = h('div', '<h1>Hello Leo</h1>')

const app = document.getElementById('app')
const oldVNode = patch(app, vnode)

let newVNode = h('div', '<div>Hello Leo</div>')

patch(oldVNode, newVNode)

刷新浏览器,就可以看到 replaceTagModule 的每个钩子都被正常执行:

img-9.png

2. 实现 updateReplaceTag() 函数

我们删除掉多余代码,接下来实现 updateReplaceTag() 函数,当 vnode 创建和更新时,都会调用该方法。

import { VNode, VNodeData } from '../vnode'
import { Module } from './module'

const regFunction = str => str && str.replace(/\<|\>|\//g, "");

const updateReplaceTag = (oldVnode: VNode, vnode: VNode): void => {
    const oldVnodeReplace = regFunction(oldVnode.text);
    const vnodeReplace = regFunction(vnode.text);
    if(oldVnodeReplace === vnodeReplace) return;
    vnode.text = vnodeReplace;
}

export const replaceTagModule: Module = {
    create: updateReplaceTag,
    update: updateReplaceTag,
}
  

updateReplaceTag() 函数中,比较新旧 vnode 的文本内容是否一致,如果一致则直接返回,否则将新的 vnode 的替换后的文本设置到 vnode 的 text 属性,完成更新。

其中有个细节:

vnode.text = vnodeReplace;

这里直接对 vnode.text 进行赋值,页面上的内容也随之发生变化。这是因为 vnode 是个响应式对象,通过调用其 setter 方法,会触发响应式更新,这样就实现页面内容更新。

于是我们看到页面内容中的 HTML 标签被清空了。

img-10.png

3. 小结

这个小节中,我们实现一个简单的 replaceTagModule 模块,体验了一下 Snabbdom 模块灵活组合的特点,当我们需要自定义某些模块时,便可以按照 Snabbdom 的模块开发方式,开发自定义模块,然后通过 Snabbdom 的 init() 函数注入模块即可。

我们再回顾一下 Snabbdom 模块系统特点:支持按需引入、独立管理、职责单一、方便组合复用、可维护性强。

五、通用模块生命周期模型

下面我将前面 Snabbdom 的模块系统,抽象为一个通用模块生命周期模型,其中包含三个核心层:

  1. 模块定义层

在本层可以按照模块开发规范,自定义各种模块。

  1. 模块应用层

一般是在业务开发层或组件层中,用来导入模块。

  1. 模块初始化层

一般是在开发的模块系统的插件中,提供初始化函数(init 函数),执行初始化函数会遍历每个 Hooks,并执行对应处理函数列表的每个函数。

抽象后的模型如下:

image.png

在使用 Module 的时候就可以灵活组合搭配使用啦,在模块初始化层,就会做好调用。

六、总结

本文主要以 Snabbdom-demo 仓库为学习示例,学习了 Snabbdom 运行流程和 Snabbdom 模块系统的运行流程,还通过手写一个简单的 Snabbdom 模块,带大家领略一下 Snabbdom 模块的魅力,最后为大家总结了一个通用模块插件模型。

大家好好掌握 Snabbdom 对理解 Vue 会很有帮助。

查看原文

赞 5 收藏 3 评论 0

阿宝哥 赞了文章 · 1月28日

WKWebView 请求拦截探索与实践

图片来源:https://unsplash.com
本文作者:谢富贵

背景

WebView 在移动端的应用场景随处可见,在云音乐里也作为许多核心业务的入口。为了满足云音乐日益复杂的业务场景,我们一直在持续不断的优化 WebView 的性能。其中可以短时间内提升 WebView 加载速度的技术之一就是离线包技术。该技术能够节省网络加载耗时,对于体积较大的网页提升效果尤为明显。离线包技术中最关键的环节就是拦截 WebView 发出的请求将资源映射到本地离线包,而对于 WKWebView 的请求拦截 iOS 系统原生并没有提供直接的能力,因此本文将围绕 WKWebView 请求拦截进行探讨。

调研

我们研究了业内已有的 WKWebView 请求拦截方案,主要分为如下两种:

NSURLProtocol
NSURLProtocol 默认会拦截所有经过 URL Loading System 的请求,因此只要 WKWebView 发出的请求经过 URL Loading System 就可以被拦截。经过我们的尝试,发现 WKWebView 独立于应用进程运行,发出去的请求默认是不会经过 URL Loading System,需要我们额外进行 hook 才能支持,具体的方式可以参考 NSURLProtocol对WKWebView的处理

WKURLSchemeHandler
WKURLSchemeHandler 是 iOS 11 引入的新特性,负责自定义请求的数据管理,如果需要支持 scheme 为 http 或 https请求的数据管理则需要 hook WKWebViewhandlesURLScheme: 方法,然后返回NO即可。
经过一番尝试和分析,我们从以下几个方面将两种方案进行对比:

  • 隔离性:NSURLProtocol 一经注册就是全局开启。一般来讲我们只会拦截自己的业务页面,但使用了 NSURLProtocol 的方式后会导致应用内合作的三方页面也会被拦截从而被污染。WKURLSchemeHandler 则可以以页面为维度进行隔离,因为是跟随着 WKWebViewConfiguration 进行配置。
  • 稳定性:NSURLProtocol 拦截过程中会丢失 Body,WKURLSchemeHandler 在 iOS 11.3 之前 (不包含) 也会丢失 Body,在 iOS 11.3 以后 WebKit 做了优化只会丢失 Blob 类型数据。
  • 一致性:WKWebView 发出的请求被 NSURLProtocol 拦截后行为可能发生改变,比如想取消 video 标签的视频加载一般都是将资源地址 (src) 设置为空,但此时 stopLoading 方法却不会调用,相比而言 WKURLSchemeHandler 表现正常。

调研的结论是:WKURLSchemeHandler 在隔离性、稳定性、一致性上表现优于 NSURLProtocol,但是想在生产环境投入使用必须要解决 Body 丢失的问题。

我们的方案

通过上文可以得知只通过 WKURLSchemeHandler 进行请求拦截是无法覆盖所有的请求场景,因为存在 Body 丢失的情况。所以我们的研究重点就是确保如何不让 Body 数据丢失或者提前拿到 Body 数据然后再将其组装成一个完整的请求发出,很显然前者需要对 WebKit 源码进行改动,成本过高,因此我们选择了后者。通过修改 JavaScript 原生的 Fetch / XMLHttpRequest 等接口实现来提前拿到 Body 数据,方案设计如下图所示:

具体流程主要为以下几点:

  • 加载 HTML 文档的时候注入自定义的 Fetch / XMLHttpRequest 对象脚本
  • 发送请求之前收集 Body 等参数通过 WKScriptMessageHandler 传递给原生应用进行存储
  • 原生应用存储完成之后调用约定好的 JavaScript 函数通知 WKWebView 保存完成
  • 调用原生 Fetch / XMLHttpRequest 等接口来发送请求
  • 请求被 WKURLSchemeHandler 管理,取出对应的 Body 等参数进行组装然后发出

脚本注入

替换 Fetch 实现

脚本注入需要修改 Fetch 接口的处理逻辑,在请求发出去之前能将 Body 等参数收集起来传递给原生应用,主要解决的问题为以下两点:

  • iOS 11.3 之前 Body 丢失问题
  • iOS 11.3 之后 Body 中 Blob 类型数据丢失问题
  1. 针对第一点需要判断在 iOS 11.3 之前的设备发出的请求是否包含请求体,如果满足则在调用原生 Fetch 接口之前需要将请求体数据收集起来传递给原生应用。
  2. 针对第二点同样需要判断在 iOS 11.3 之后的设备发出的请求是否包含请求体且请求体中是否带有 Blob 类型数据,如果满足则同上处理。

其余情况只需直接调用原生 Fetch 接口即可,保持原生逻辑。

var nativeFetch = window.fetch
var interceptMethodList = ['POST', 'PUT', 'PATCH', 'DELETE'];
window.fetch = function(url, opts) {
 // 判断是否包含请求体
 var hasBodyMethod = opts != null && opts.method != null && (interceptMethodList.indexOf(opts.method.toUpperCase()) !== -1);
 if (hasBodyMethod) {
 // 判断是否为iOS 11.3之前(可通过navigate.userAgent判断)
 var shouldSaveParamsToNative = isLessThan11_3;
 if (!shouldSaveParamsToNative) {
 // 如果为iOS 11.3之后请求体是否带有Blob类型数据
 shouldSaveParamsToNative = opts != null ? isBlobBody(opts) : false;
 }
 if (shouldSaveParamsToNative) {
 // 此时需要收集请求体数据保存到原生应用
 return saveParamsToNative(url, opts).then(function (newUrl) {
 // 应用保存完成后调用原生fetch接口
 return nativeFetch(newUrl, opts)
 });
 }
 }
 // 调用原生fetch接口
 return nativeFetch(url, opts);
}

保存请求体数据到原生应用

通过 WKScriptMessageHandler 接口就能将请求体数据保存到原生应用,并且需要生成一个唯一标识符对应到具体的请求体数据以便后续取出。我们的思路是生成标准的 UUID 作为标识符然后随着请求体数据一起传递给原生应用进行保存,然后再将 UUID 标识符拼接到请求链接后,请求被 WKURLSchemeHandler 管理后会通过该标识符去获取具体的请求体数据然后组装成请求发出。

function saveParamsToNative(url, opts) {
 return new Promise(function (resolve, reject) {
 // 构造标识符
 var identifier = generateUUID();
 var appendIdentifyUrl = urlByAppendIdentifier(url, "identifier", identifier)
 // 解析body数据并保存到原生应用
 if (opts && opts.body) {
 getBodyString(opts.body, function(body) {
 // 设置保存完成回调,原生应用保存完成后调用此js函数后将请求发出
 finishSaveCallbacks[identifier] = function() {
 resolve(appendIdentifyUrl)
 }
 // 通知原生应用保存请求体数据
 window.webkit.messageHandlers.saveBodyMessageHandler.postMessage({'body': body, 'identifier': identifier}})
 });
 }else {
 resolve(url);
 }
 });
}

请求体解析

Fetch 接口中可以通过第二个 opts 参数拿到请求体参数即 opts.body,参考 MDN Fetch Body 可得知请求体的类型有七种。经过分析,可以将这七种数据类型分为三类进行解析编码处理,将 ArrayBufferArrayBufferViewBlobFile 归类为二进制类型,stringURLSearchParams 归类为字符串类型,FormData 归类为复合类型,最后统一转换成字符串类型返回给原生应用。

function getBodyString(body, callback) {
 if (typeof body == 'string') {
 callback(body)
 }else if(typeof body == 'object') {
 if (body instanceof ArrayBuffer) body = new Blob([body])
 if (body instanceof Blob) {
 // 将Blob类型转换为base64
 var reader = new FileReader()
 reader.addEventListener("loadend", function() {
 callback(reader.result.split(",")[1])
 })
 reader.readAsDataURL(body)
 } else if(body instanceof FormData) {
 generateMultipartFormData(body)
 .then(function(result) {
 callback(result)
 });
 } else if(body instanceof URLSearchParams) {
 // 遍历URLSearchParams进行键值对拼接
 var resultArr = []
 for (pair of body.entries()) {
 resultArr.push(pair[0] + '=' + pair[1])
 }
 callback(resultArr.join('&'))
 } else {
 callback(body);
 }
 }else {
 callback(body);
 }
}

二进制类型为了方便传输统一转换成 Base64 编码。字符串类型中 URLSearchParams 遍历之后可得到键值对。复合类型存储结构类似为字典,值可能为 string 或者 Blob 类型,所以需要遍历然后按照 Multipart/form-data 格式进行拼接。

其它

注入的脚本主要内容如上述所示,示例中只是替换了 Fetch 的实现,XMLHttpRequest 也是按照同样的思路进行替换即可。云音乐由于最低版本支持到 iOS 11.0,而 FormData.prototype.entries 是在 iOS 11.2 以后的版本才支持,对于之前的版本可以修改 FormData.prototype.set 方法的实现来保存键值对,这里不多加赘述。除此之外,请求可能是由内嵌的 iframe 发出,此时直接调用 finishSaveCallbacks[identifier]() 是无效的,因为 finishSaveCallbacks 是挂载在 Main Window 上的,可以考虑使用 window.postMessage 方法来跟子 Window 进行通信。

WKURLSchemeHandler 拦截请求

WKURLSchemeHandler 的注册和使用这里不再多加叙述,具体的可以参考上文中的调研部分以及苹果文档,这里我们主要聊一聊拦截过程中要注意的点

重定向

一些读者可能会注意到上文调研部分我们在介绍 WKURLSchemeHandler 时把它的作用定义为自定义请求的数据管理。那么为什么不是自定义请求的数据拦截呢?理论上拦截是不需要开发者关心请求逻辑,开发者只用处理好过程中的数据即可。而对于数据管理开发者需要关注过程中的所有逻辑,然后将最终的数据返回。带着这两个定义,我们再一起对比下 WKURLSchemeTaskNSURLProtocol 协议,可见后者比前者多了重定向、鉴权等相关请求处理逻辑。

API_AVAILABLE(macos(10.13), ios(11.0))
@protocol WKURLSchemeTask <NSObject>
@property (nonatomic, readonly, copy) NSURLRequest *request;
- (void)didReceiveResponse:(NSURLResponse *)response;
- (void)didReceiveData:(NSData *)data;
- (void)didFinish;
- (void)didFailWithError:(NSError *)error;
@end
API_AVAILABLE(macos(10.2), ios(2.0), watchos(2.0), tvos(9.0))
@protocol NSURLProtocolClient <NSObject>
- (void)URLProtocol:(NSURLProtocol *)protocol didReceiveResponse:(NSURLResponse *)response cacheStoragePolicy:(NSURLCacheStoragePolicy)policy;
- (void)URLProtocol:(NSURLProtocol *)protocol didLoadData:(NSData *)data;
- (void)URLProtocolDidFinishLoading:(NSURLProtocol *)protocol;
- (void)URLProtocol:(NSURLProtocol *)protocol didFailWithError:(NSError *)error;
- (void)URLProtocol:(NSURLProtocol *)protocol didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge;
- (void)URLProtocol:(NSURLProtocol *)protocol didCancelAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge;
@end

那么该如何在拦截过程中处理重定向响应?我们尝试着每次收到响应时都调用 didReceiveResponse: 方法,发现中间的重定向响应都会被最后接收到的响应覆盖掉,这样则会导致 WKWebView 无法感知到重定向,从而不会改变地址等相关信息,对于一些有判断路由的页面可能会带来一些意想不到的影响。 此时我们再次陷入困境,可以看出 WKURLSchemeHandler 在获取数据时并不支持重定向,因为苹果当初设计的时候只是把它作为单纯的数据管理。其实每次响应我们都能拿到,只不过不能完整的传递给 WKWebView 而已。经过一番衡量,我们基于以下三点原因最终选择了重新加载的方式来解决 HTML 文档请求重定向的问题。

  • 目前能修改的只有 FetchXMLHttpRequest 接口的实现,对于文档请求和 HTML 标签发起请求都是浏览器内部行为,修改源码成本太大。
  • FetchXMLHttpRequest 默认只会返回最终的响应,所以在服务端接口层面保证最终数据正确,丢失重定向响应影响不大。
  • 图片 / 视频 / 表单 / 样式表 / 脚本等资源同理也一般只需关系最终的数据正确即可。

接收到 HTML 文档的重定向响应则直接返回给 WKWebView 并取消后续加载。而对于其它资源的重定向,则选择丢弃。

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task willPerformHTTPRedirection:(NSHTTPURLResponse *)response newRequest:(NSURLRequest *)request completionHandler:(void (^)(NSURLRequest * _Nullable))completionHandler { 
 NSString *originUrl = task.originalRequest.URL.absoluteString;
 if ([originUrl isEqualToString:currentWebViewUrl]) {
 [urlSchemeTask didReceiveResponse:response];
 [urlSchemeTask didFinish];
 completionHandler(nil);
 }else {
 completionHandler(request);
 }
}

WKWebView 收到响应数据后会调用 webView:decidePolicyForNavigationResponse:decisionHandler 方法来决定最后的跳转,在该方法中可以拿到重定向的目标地址 Location 进行重新加载。

- (void)webView:(WKWebView *)webView decidePolicyForNavigationResponse:(WKNavigationResponse *)navigationResponse decisionHandler:(void (^)(WKNavigationResponsePolicy))decisionHandler
{
 // 开启了拦截
 if (enableNetworkIntercept) {
 if ([navigationResponse.response isKindOfClass:[NSHTTPURLResponse class]]) {
 NSHTTPURLResponse *httpResp = (NSHTTPURLResponse *)navigationResponse.response;
 NSInteger statusCode = httpResp.statusCode;
 NSString *redirectUrl = [httpResp.allHeaderFields stringForKey:@"Location"];
 if (statusCode >= 300 && statusCode < 400 && redirectUrl) {
 decisionHandler(WKNavigationActionPolicyCancel);
 // 不支持307、308post跳转情景
 [webView loadHTMLWithUrl:redirectUrl]; 
 return;
 }
 }
 }
 decisionHandler(WKNavigationResponsePolicyAllow);
}

至此 HTML 文档重定向问题基本上暂告一段落,到本文发布之前我们还未发现一些边界问题,当然如果大家还有其它好的想法也欢迎随时讨论。

Cookie 同步

由于 WKWebView 与我们的应用不是同一个进程所以 WKWebViewNSHTTPCookieStorage 并不同步。这里不展开讲 WKWebView Cookie 同步的整个过程,只重点讨论下拦截过程中的 Cookie 同步。由于请求最终是由原生应用发出的,所以 Cookie 读取和存储都是走 NSHTTPCookieStorage。值得注意的是,WKURLSchemeHandler 返回给 WKWebView 的响应中包含 Set-Cookie 信息,但是 WKWebView 并未设置到 document.cookie 上。在这里也可以佐证上文所述: WKURLSchemeHandler 只是负责数据管理,请求中涉及的逻辑需要开发者自行处理。
WKWebView 的 Cookie 同步可以通过 WKHTTPCookieStore 对象来实现

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler
{
 if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
 NSHTTPURLResponse *httpResp = (NSHTTPURLResponse *)response;
 NSArray <NSHTTPCookie *>*responseCookies = [NSHTTPCookie cookiesWithResponseHeaderFields:[httpResp allHeaderFields] forURL:response.URL];
 if ([responseCookies isKindOfClass:[NSArray class]] && responseCookies.count > 0) {
 dispatch_async(dispatch_get_main_queue(), ^{
 [responseCookies enumerateObjectsUsingBlock:^(NSHTTPCookie * _Nonnull cookie, NSUInteger idx, BOOL * _Nonnull stop) {
 // 同步到WKWebView
 [[WKWebsiteDataStore defaultDataStore].httpCookieStore setCookie:cookie completionHandler:nil];
 }];
 });
 }
 }
 completionHandler(NSURLSessionResponseAllow);
}

拦截过程中除了把原生应用的 Cookie 同步到 WKWebView, 在修改 document.cookie 时也要同步到原生应用。经过尝试发现真机设备上 document.cookie 在修改后会主动延迟同步到 NSHTTPCookieStorage 中,但是模拟器并未做任何同步。对于一些修改完 document.cookie 就立刻发出去的请求可能不会立即带上改动的 Cookie 信息,因为拦截之后 Cookie 是走 NSHTTPCookieStorage 的。
我们的方案是修改 document.cookie setter 方法实现,在 Cookie 设置完成之前先同步到原生应用。注意原生应用此时需要做好跨域校验,防止恶意页面对 Cookie 进行任意修改。

(function() {
 var cookieDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'cookie') || Object.getOwnPropertyDescriptor(HTMLDocument.prototype, 'cookie');
 if (cookieDescriptor && cookieDescriptor.configurable) {
 Object.defineProperty(document, 'cookie', {
 configurable: true,
 enumerable: true,
 set: function (val) {
 // 设置时先传递给原生应用才生效
 window.webkit.messageHandlers.save.postMessage(val);
 cookieDescriptor.set.call(document, val);
 },
 get: function () {
 return cookieDescriptor.get.call(document);
 }
 });
 }
})()

NSURLSession 导致的内存泄露

通过 NSURLSessionsessionWithConfiguration:delegate:delegateQueue 构造方法来创建对象时 delegate 是被 NSURLSession 强引用的,这一点大家比较容易忽视。我们会为每一个 WKURLSchemeHandler 对象创建一个 NSURLSession 对象然后将前者设置为后者的 delegate,这样就导致循环引用的产生。建议在 WKWebView 销毁时调用 NSURLSessioninvalidateAndCancel 方法来解除对 WKURLSchemeHandler 对象的强引用。

稳定性提升

经过上文可以看出如果跟系统 “对着干”(WKWebView 本身就不支持 http/https 请求拦截),会有很多意想不到的事情发生,也可能有很多的边界地方需要覆盖,所以我们必须得有一套完善的措施来提升拦截过程中的稳定性。

动态下发

我们可以通过动态下发黑名单的方式来关掉一些页面的拦截。云音乐默认会预加载两个空 WKWebView,一个是注册了 WKURLSchemeHandlerWKWebView 来加载主站页面,并且支持黑名单关闭,另外一个则是普通的 WKWebView 来加载一些三方页面(因为三方页面的逻辑比较多样和复杂,而且我们也没有必要去拦截三方页面的请求)。除此之外对于一些刚开始尝试通过脚本注入来解决请求体丢失的团队,可能覆盖不了所有的场景,可以尝试动态下发的方式更新脚本,同样要对脚本内容做好签名防止别人恶意篡改。

监控

日志收集能帮助我们更好的去发现潜在的问题。拦截过程中所有的请求逻辑都统一收拢在 WKURLSchemeHandler 中,我们可以在一些关键链路上进行日志收集。比如可以收集注入的脚本是否执行异常、接收到 Body 是否丢失、返回的响应状态码是否正常等等。

完全代理请求

除上述措施外我们还可以将网络请求比如服务端 API 接口完全代理给客户端。前端只用将相应的参数通过 JSBridge 方式传递给原生应用然后通过原生应用的网络请求通道来获取数据。该方式除了能减少拦截过程中潜在问题的发生,还能复用原生应用的一些网络相关的能力比如 HTTP DNS、反作弊等。而且值得注意的是 iOS 14 苹果在 WKWebView 默认开启了 ITP (Intelligent Tracking Prevention) 智能防跟踪功能,受影响的地方主要是跨域 Cookie 和 Storage 等的使用。比如我们应用里有一些三方页面需要通过一个 iframe 内嵌我们的页面来达到授权能力,此时由于跨域默认是获取不到我们主站域名下的 Cookie, 如果走原生应用的代理请求就能解决类似的问题。最后再次提醒大家如果使用这种方式记得做好鉴权校验,防止一些恶意页面调用该能力,毕竟原生应用的请求是没有跨域限制的。

小结

本文将 iOS 原生 WKURLSchemeHandlerJavaScript 脚本注入结合在一起,实现了 WKWebView 在离线包加载、免流等业务中需要的请求拦截能力,解决了拦截过程中可能存在的重定向、请求体丢失、Cookie 不同步等问题并能以页面为维度进行拦截隔离。在探索过程中我们愈发的感受到技术是没有边界的,有时候可能由于平台的一些限制,单靠一方是无法实现一套完整的能力。只有将相关平台的技术能力结合在一起,才能制定出一套合理的技术方案。最后,本文是我们在 WKWebView 请求拦截的一些探索实践,如有错误欢迎指正与交流。

本文发布自 网易云音乐大前端团队,文章未经授权禁止任何形式的转载。我们常年招收前端、iOS、Android,如果你准备换工作,又恰好喜欢云音乐,那就加入我们 grp.music-fe(at)corp.netease.com!
查看原文

赞 2 收藏 1 评论 1

阿宝哥 发布了文章 · 1月25日

从观察者模式到响应式的设计原理

响应式对使用过 Vue 或 RxJS 的小伙伴来说,应该都不会陌生。响应式也是 Vue 的核心功能特性之一,因此如果要想掌握 Vue,我们就必须深刻理解响应式。接下来阿宝哥将从观察者模式说起,然后结合 observer-util 这个库,带大家一起深入学习响应式的原理。

一、观察者模式

观察者模式,它定义了一种 一对多 的关系,让多个观察者对象同时监听某一个主题对象,这个主题对象的状态发生变化时就会通知所有的观察者对象,使得它们能够自动更新自己。在观察者模式中有两个主要角色:Subject(主题)和 Observer(观察者)。

关注「全栈修仙之路」阅读阿宝哥原创的 4 本免费电子书(累计下载2.2万+)及 50 几篇 “重学TS” 教程。

由于观察者模式支持简单的广播通信,当消息更新时,会自动通知所有的观察者。下面我们来看一下如何使用 TypeScript 来实现观察者模式:

1.1 定义 ConcreteObserver

interface Observer {
  notify: Function;
}

class ConcreteObserver implements Observer{
  constructor(private name: string) {}
  notify() {
    console.log(`${this.name} has been notified.`);
  }
}

1.2 定义 Subject 类

class Subject { 
  private observers: Observer[] = [];

  public addObserver(observer: Observer): void {
    this.observers.push(observer);
  }

  public notifyObservers(): void {
    console.log("notify all the observers");
    this.observers.forEach(observer => observer.notify());
  }
}

1.3 使用示例

// ① 创建主题对象
const subject: Subject = new Subject();

// ② 添加观察者
const observerA = new ConcreteObserver("ObserverA");
const observerC = new ConcreteObserver("ObserverC");
subject.addObserver(observerA); 
subject.addObserver(observerC);

// ③ 通知所有观察者
subject.notifyObservers();

对于以上的示例来说,主要包含三个步骤:① 创建主题对象、② 添加观察者、③ 通知观察者。上述代码成功运行后,控制台会输出以下结果:

notify all the observers
ObserverA has been notified.
ObserverC has been notified.

在前端大多数场景中,我们所观察的目标是数据,当数据发生变化的时候,页面能实现自动的更新,对应的效果如下图所示:

要实现自动更新,我们需要满足两个条件:一个是能实现精准地更新,另一个是能检测到数据的异动。要能实现精准地更新就需要收集对该数据异动感兴趣的更新函数(观察者),在完成收集之后,当检测到数据异动,就可以通知对应的更新函数。

上面的描述看起来比较绕,其实要实现自动更新,我们就是要让 ① 创建主题对象、② 添加观察者、③ 通知观察者 这三个步骤实现自动化,这就是实现响应式的核心思路。接下来,我们来举一个具体的示例:

相信熟悉 Vue2 响应式原理的小伙伴,对上图中的代码都不会陌生,其中第二步骤也被称为收集依赖。通过使用 Object.defineProperty API,我们可以拦截对数据的读取和修改操作。若在函数体中对某个数据进行读取,则表示此函数对该数据的异动感兴趣。当进行数据读取时,就会触发已定义的 getter 函数,这时就可以把数据的观察者存储起来。而当数据发生异动的时候,我们就可以通知观察者列表中的所有观察者,从而执行相应的更新操作。

Vue3 使用了 Proxy API 来实现响应式,Proxy API 相比 Object.defineProperty API 有哪些优点呢?这里阿宝哥不打算展开介绍了,后面打算写一篇专门的文章来介绍 Proxy API。下面阿宝哥将开始介绍本文的主角 —— observer-util

Transparent reactivity with 100% language coverage. Made with ❤️ and ES6 Proxies.

https://github.com/nx-js/obse...

该库内部也是利用了 ES6 的 Proxy API 来实现响应式,在介绍它的工作原理前,我们先来看一下如何使用它。

二、observer-util 简介

observer-util 这个库使用起来也很简单,利用该库提供的 observableobserve 函数,我们就可以方便地实现数据的响应式。下面我们先来举个简单的例子:

2.1 已知属性

import { observable, observe } from '@nx-js/observer-util';

const counter = observable({ num: 0 });
const countLogger = observe(() => console.log(counter.num)); // 输出 0

counter.num++; // 输出 1

在以上代码中,我们从 @nx-js/observer-util 模块中分别导入 observableobserve 函数。其中 observable 函数用于创建可观察的对象,而 observe 函数用于注册观察者函数。以上的代码成功执行后,控制台会依次输出 01。除了已知属性外,observer-util 也支持动态属性。

2.2 动态属性

import { observable, observe } from '@nx-js/observer-util';

const profile = observable();
observe(() => console.log(profile.name));

profile.name = 'abao'; // 输出 'abao'

以上的代码成功执行后,控制台会依次输出 undefinedabaoobserver-util 除了支持普通对象之外,它还支持数组和 ES6 中的集合,比如 Map、Set 等。这里我们以常用的数组为例,来看一下如何让数组对象变成响应式对象。

2.3 数组

import { observable, observe } from '@nx-js/observer-util';

const users = observable([]);

observe(() => console.log(users.join(', ')));

users.push('abao'); // 输出 'abao'

users.push('kakuqo'); // 输出 'abao, kakuqo'

users.pop(); // 输出 'abao,'

这里阿宝哥只介绍了几个简单的示例,对 observer-util 其他使用示例感兴趣的小伙伴,可以阅读该项目的 README.md 文档。接下来,阿宝哥将以最简单的例子为例,来分析一下 observer-util 这个库响应式的实现原理。

如果你想在本地运行以上示例的话,可以先修改 debug/index.js 目录下的 index.js 文件,然后在根目录下执行 npm run debug 命令。

三、observer-util 原理解析

首先,我们再来回顾一下最早的那个例子:

import { observable, observe } from '@nx-js/observer-util';

const counter = observable({ num: 0 }); // A
const countLogger = observe(() => console.log(counter.num)); // B

counter.num++; // C

在第 A 行中,我们通过 observable 函数创建了可观察的 counter 对象,该对象的内部结构如下:

通过观察上图可知,counter 变量所指向的是一个 Proxy 对象,该对象含有 3 个 Internal slots。那么 observable 函数是如何将我们的 { num: 0 } 对象转换成 Proxy 对象呢?在项目的 src/observable.js 文件中,我们找到了该函数的定义:

// src/observable.js
export function observable (obj = {}) {
  // 如果obj已经是一个observable对象或者不应该被包装,则直接返回它
  if (proxyToRaw.has(obj) || !builtIns.shouldInstrument(obj)) {
    return obj
  }

  // 如果obj已经有一个对应的observable对象,则将其返回。否则创建一个新的observable对象
  return rawToProxy.get(obj) || createObservable(obj)
}

在以上代码中出现了 proxyToRawrawToProxy 两个对象,它们被定义在 src/internals.js 文件中:

// src/internals.js
export const proxyToRaw = new WeakMap()
export const rawToProxy = new WeakMap()

这两个对象分别存储了 proxy => rawraw => proxy 之间的映射关系,其中 raw 表示原始对象,proxy 表示包装后的 Proxy 对象。很明显首次执行时,proxyToRaw.has(obj)rawToProxy.get(obj) 分别会返回 falseundefined,所以会执行 || 运算符右侧的逻辑。

下面我们来分析一下 shouldInstrument 函数,该函数的定义如下:

// src/builtIns/index.js
export function shouldInstrument ({ constructor }) {
  const isBuiltIn =
    typeof constructor === 'function' &&
    constructor.name in globalObj &&
    globalObj[constructor.name] === constructor
  return !isBuiltIn || handlers.has(constructor)
}

shouldInstrument 函数内部,会使用参数 obj 的构造函数判断其是否为内置对象,对于 { num: 0 } 对象来说,它的构造函数是 ƒ Object() { [native code] },因此 isBuiltIn 的值为 true,所以会继续执行 || 运算符右侧的逻辑。其中 handlers 对象是一个 Map 对象:

// src/builtIns/index.js
const handlers = new Map([
  [Map, collectionHandlers],
  [Set, collectionHandlers],
  [WeakMap, collectionHandlers],
  [WeakSet, collectionHandlers],
  [Object, false],
  [Array, false],
  [Int8Array, false],
  [Uint8Array, false],
  // 省略部分代码
  [Float64Array, false]
])

看完 handlers 的结构,很明显 !builtIns.shouldInstrument(obj) 表达式的结果为 false。所以接下来,我们的焦点就是 createObservable 函数:

function createObservable (obj) {
  const handlers = builtIns.getHandlers(obj) || baseHandlers
  const observable = new Proxy(obj, handlers)
  // 保存raw => proxy,proxy => raw 之间的映射关系
  rawToProxy.set(obj, observable)
  proxyToRaw.set(observable, obj)
  storeObservable(obj)
  return observable
}

通过观察以上代码,我们就知道了为什么调用 observable({ num: 0 }) 函数之后,返回的是一个 Proxy 对象。对于 Proxy 的构造函数来说,它支持两个参数:

const p = new Proxy(target, handler)
  • target:要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理);
  • handler:一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理 p 的行为。

示例中的 target 指向的就是 { num: 0 } 对象,而 handlers 的值会根据 obj 的类型而返回不同的 handlers

// src/builtIns/index.js
export function getHandlers (obj) {
  return handlers.get(obj.constructor) // [Object, false],
}

baseHandlers 是一个包含了 get、has 和 set 等 “陷阱“ 的对象:

export default { get, has, ownKeys, set, deleteProperty }

在创建完 observable 对象之后,会保存 raw => proxy,proxy => raw 之间的映射关系,然后再调用 storeObservable 函数执行存储操作,storeObservable 函数被定义在 src/store.js 文件中:

// src/store.js
const connectionStore = new WeakMap()

export function storeObservable (obj) {
  // 用于后续保存obj.key -> reaction之间映射关系
  connectionStore.set(obj, new Map())
}

介绍了那么多,阿宝哥用一张图来总结一下前面的内容:

至于 proxyToRawrawToProxy 对象有什么用呢?相信看完以下代码,你就会知道答案。

// src/observable.js
export function observable (obj = {}) {
  // 如果obj已经是一个observable对象或者不应该被包装,则直接返回它
  if (proxyToRaw.has(obj) || !builtIns.shouldInstrument(obj)) {
    return obj
  }

  // 如果obj已经有一个对应的observable对象,则将其返回。否则创建一个新的observable对象
  return rawToProxy.get(obj) || createObservable(obj)
}

下面我们来开始分析第 B 行:

const countLogger = observe(() => console.log(counter.num)); // B

observe 函数被定义在 src/observer.js 文件中,其具体定义如下:

// src/observer.js
export function observe (fn, options = {}) {
  // const IS_REACTION = Symbol('is reaction')
  const reaction = fn[IS_REACTION]
    ? fn
    : function reaction () {
      return runAsReaction(reaction, fn, this, arguments)
    }
  // 省略部分代码
  reaction[IS_REACTION] = true
  // 如果非lazy,则直接运行
  if (!options.lazy) {
    reaction()
  }
  return reaction
}

在上面代码中,会先判断传入的 fn 是不是 reaction 函数,如果是的话,直接使用它。如果不是的话,会把传入的 fn 包装成 reaction 函数,然后再调用该函数。在 reaction 函数内部,会调用另一个函数 —— runAsReaction,顾名思义该函数用于运行 reaction 函数。

runAsReaction 函数被定义在 src/reactionRunner.js 文件中:

// src/reactionRunner.js
const reactionStack = []

export function runAsReaction (reaction, fn, context, args) {
  // 省略部分代码
  if (reactionStack.indexOf(reaction) === -1) {
    // 释放(obj -> key -> reactions) 链接并复位清理器链接
    releaseReaction(reaction)

    try {
      // 压入到reactionStack堆栈中,以便于在get陷阱中能建立(observable.prop -> reaction)之间的联系
      reactionStack.push(reaction)
      return Reflect.apply(fn, context, args)
    } finally {
      // 从reactionStack堆栈中,移除已执行的reaction函数
      reactionStack.pop()
    }
  }
}

runAsReaction 函数体中,会把当前正在执行的 reaction 函数压入 reactionStack 栈中,然后使用 Reflect.apply API 调用传入的 fn 函数。当 fn 函数执行时,就是执行 console.log(counter.num) 语句,在该语句内,会访问 counter 对象的 num 属性。 counter 对象是一个 Proxy 对象,当访问该对象的属性时,会触发 baseHandlersget 陷阱:

// src/handlers.js
function get (target, key, receiver) {
  const result = Reflect.get(target, key, receiver)
  // 注册并保存(observable.prop -> runningReaction)
  registerRunningReactionForOperation({ target, key, receiver, type: 'get' })
  const observableResult = rawToProxy.get(result)
  if (hasRunningReaction() && typeof result === 'object' && result !== null) {
    // 省略部分代码
  }
  return observableResult || result
}

在以上的函数中,registerRunningReactionForOperation 函数用于保存 observable.prop -> runningReaction 之间的映射关系。其实就是为对象的指定属性,添加对应的观察者,这是很关键的一步。所以我们来重点分析 registerRunningReactionForOperation 函数:

// src/reactionRunner.js
export function registerRunningReactionForOperation (operation) {
  // 从栈顶获取当前正在执行的reaction
  const runningReaction = reactionStack[reactionStack.length - 1]
  if (runningReaction) {
    debugOperation(runningReaction, operation)
    registerReactionForOperation(runningReaction, operation)
  }
}

registerRunningReactionForOperation 函数中,首先会从 reactionStack 堆栈中获取正在运行的 reaction 函数,然后再次调用 registerReactionForOperation 函数为当前的操作注册 reaction 函数,具体的处理逻辑如下所示:

// src/store.js
export function registerReactionForOperation (reaction, { target, key, type }) {
  // 省略部分代码
  const reactionsForObj = connectionStore.get(target) // A
  let reactionsForKey = reactionsForObj.get(key) // B
  if (!reactionsForKey) { // C
    reactionsForKey = new Set()
    reactionsForObj.set(key, reactionsForKey)
  }
  if (!reactionsForKey.has(reaction)) { // D
    reactionsForKey.add(reaction)
    reaction.cleaners.push(reactionsForKey)
  }
}

在调用 observable(obj) 函数创建可观察对象时,会为以 obj 对象为 key,保存在 connectionStoreconnectionStore.set(obj, new Map()) )对象中。阿宝哥把 registerReactionForOperation 函数内部的处理逻辑分为 4 个部分:

  • (A):从 connectionStore (WeakMap)对象中获取 target 对应的值,会返回一个 reactionsForObj(Map)对象;
  • (B):从 reactionsForKey (Map)对象中获取 key(对象属性)对应的值,如果不存在的话,会返回 undefined;
  • (C):如果 reactionsForKey 为 undefined,则会创建一个 Set 对象,并把该对象作为 value,保存在 reactionsForObj(Map)对象中;
  • (D):判断 reactionsForKey(Set)集合中是否含有当前的 reaction 函数,如果不存在的话,把当前的 reaction 函数添加到 reactionsForKey(Set)集合中。

为了让大家能够更好地理解该部分的内容,阿宝哥继续通过画图来总结上述的内容:

因为对象中的每个属性都可以关联多个 reaction 函数,为了避免出现重复,我们使用 Set 对象来存储每个属性所关联的 reaction 函数。而一个对象又可以包含多个属性,所以 observer-util 内部使用了 Map 对象来存储每个属性与 reaction 函数之间的关联关系。

此外,为了支持能把多个对象变成 observable 对象并在原始对象被销毁时能及时地回收内存, observer-util 定义了 WeakMap 类型的 connectionStore 对象来存储对象的链接关系。对于当前的示例,connectionStore 对象的内部结构如下所示:

最后,我们来分析 counter.num++; 这行代码。简单起见,阿宝哥只分析核心的处理逻辑,对完整代码感兴趣的小伙伴,可以阅读该项目的源码。当执行 counter.num++; 这行代码时,会触发已设置的 set 陷阱:

// src/handlers.js
function set (target, key, value, receiver) {
  // 省略部分代码
  const hadKey = hasOwnProperty.call(target, key)
  const oldValue = target[key]
  const result = Reflect.set(target, key, value, receiver)
  if (!hadKey) {
    queueReactionsForOperation({ target, key, value, receiver, type: 'add' })
  } else if (value !== oldValue) {
    queueReactionsForOperation({
      target,
      key,
      value,
      oldValue,
      receiver,
      type: 'set'
    })
  }
  return result
}

对于我们的示例,将会调用 queueReactionsForOperation 函数:

// src/reactionRunner.js
export function queueReactionsForOperation (operation) {
  // iterate and queue every reaction, which is triggered by obj.key mutation
  getReactionsForOperation(operation).forEach(queueReaction, operation)
}

queueReactionsForOperation 函数内部会继续调用 getReactionsForOperation 函数获取当前 key 对应的 reactions:

// src/store.js
export function getReactionsForOperation ({ target, key, type }) {
  const reactionsForTarget = connectionStore.get(target)
  const reactionsForKey = new Set()

  if (type === 'clear') {
    reactionsForTarget.forEach((_, key) => {
      addReactionsForKey(reactionsForKey, reactionsForTarget, key)
    })
  } else {
    addReactionsForKey(reactionsForKey, reactionsForTarget, key)
  }
    // 省略部分代码
  return reactionsForKey
}

在成功获取当前 key 对应的 reactions 对象之后,会遍历该对象执行每个 reaction,具体的处理逻辑被定义在 queueReaction 函数中:

// src/reactionRunner.js
function queueReaction (reaction) {
  debugOperation(reaction, this)
  // queue the reaction for later execution or run it immediately
  if (typeof reaction.scheduler === 'function') {
    reaction.scheduler(reaction)
  } else if (typeof reaction.scheduler === 'object') {
    reaction.scheduler.add(reaction)
  } else {
    reaction()
  }
}

因为我们的示例并没有配置 scheduler 参数,所以就会直接执行 else 分支的代码,即执行 reaction() 该语句。好的,observer-util 这个库内部如何把普通对象转换为可观察对象的核心逻辑已经分析完了。对于普通对象来说,observer-util 内部通过 Proxy API 提供 get 和 set 陷阱,实现自动添加观察者(添加 reaction 函数)和通知观察者(执行 reaction 函数)的处理逻辑。

如果你看完本文所介绍的内容,应该就可以理解 Vue3 中 reactivity 模块内 targetMap 的相关定义:

// vue-next/packages/reactivity/src/effect.ts
type Dep = Set<ReactiveEffect>
type KeyToDepMap = Map<any, Dep>
const targetMap = new WeakMap<any, KeyToDepMap>()

除了普通对象和数组之外,observer-util 还支持 ES6 中的集合,比如 Map、Set 和 WeakMap 等。当处理这些对象时,在创建 Proxy 对象时,会使用 collectionHandlers 对象,而不是 baseHandlers 对象。这部分内容,阿宝哥就不再展开介绍,感兴趣的小伙伴可以自行阅读相关代码。

关注「全栈修仙之路」阅读阿宝哥原创的 4 本免费电子书(累计下载 2.2万+)及 10 篇源码分析系列教程。

四、参考资源

查看原文

赞 3 收藏 3 评论 0

阿宝哥 赞了文章 · 1月18日

探索 Vue.js 响应式原理

提到“响应式”三个字,大家立刻想到啥?响应式布局?响应式编程?

响应式关键词.png

从字面意思可以看出,具有“响应式”特征的事物会根据条件变化,使得目标自动作出对应变化。比如在“响应式布局”中,页面根据不同设备尺寸自动显示不同样式。

Vue.js 中的响应式也是一样,当数据发生变化后,使用到该数据的视图也会相应进行自动更新。

接下来我根据个人理解,和大家一起探索下 Vue.js 中的响应式原理,如有错误,欢迎指点😺~~

一、Vue.js 响应式的使用

现在有个很简单的需求,点击页面中 “leo” 文本后,文本内容修改为“你好,前端自习课”。

我们可以直接操作 DOM,来完成这个需求:

<span id="name">leo</span>
const node = document.querySelector('#name')
node.innerText = '你好,前端自习课';

实现起来比较简单,当我们需要修改的数据有很多时(比如相同数据被多处引用),这样的操作将变得复杂。

既然说到 Vue.js,我们就来看看 Vue.js 怎么实现上面需求:

<template>
  <div id="app">
    <span @click="setName">{{ name }}</span>
  </div>
</template>

<script>
export default {
  name: "App",
  data() {
    return {
      name: "leo",
    };
  },
  methods: {
    setName() {
      this.name = "你好,前端自习课";
    },
  },
};
</script>

观察上面代码,我们通过改变数据,来自动更新视图。当我们有多个地方引用这个 name 时,视图都会自动更新。

<template>
  <div id="app">
    <span @click="setName">{{ name }}</span>
    <span>{{ name }}</span>
    <span>{{ name }}</span>
    <span>{{ name }}</span>
  </div>
</template>

当我们使用目前主流的前端框架 Vue.js 和 React 开发业务时,只需关注页面数据如何变化,因为数据变化后,视图也会自动更新,这让我们从繁杂的 DOM 操作中解脱出来,提高开发效率。

二、回顾观察者模式

前面反复提到“通过改变数据,来自动更新视图”,换个说法就是“数据改变后,使用该数据的地方被动发生响应,更新视图”。

是不是有种熟悉的感觉?数据无需关注自身被多少对象引用,只需在数据变化时,通知到引用的对象即可,引用的对象作出响应。恩,有种观察者模式的味道?

关于观察者模式,可阅读我之前写的《图解设计模式之观察者模式(TypeScript)》

1. 观察者模式流程

观察者模式表示一种“一对多”的关系,n 个观察者关注 1 个被观察者,被观察者可以主动通知所有观察者。接下图:

observer.png
在这张图中,粉丝想及时收到“前端自习课”最新文章,只需关注即可,“前端自习课”有新文章,会主动推送给每个粉丝。该过程中,“前端自习课”是被观察者,每位“粉丝”是观察者。

2. 观察者模式核心

观察者模式核心组成包括:n 个观察者和 1 个被观察者。这里实现一个简单观察者模式:

2.1 定义接口

// 观察目标接口
interface ISubject {
    addObserver: (observer: Observer) => void; // 添加观察者
    removeObserver: (observer: Observer) => void; // 移除观察者
    notify: () => void; // 通知观察者
}

// 观察者接口
interface IObserver {
    update: () => void;
}

2.2 实现被观察者类

// 实现被观察者类
class Subject implements ISubject {
    private observers: IObserver[] = [];

    public addObserver(observer: IObserver): void {
        this.observers.push(observer);
    }

    public removeObserver(observer: IObserver): void {
        const idx: number = this.observers.indexOf(observer);
        ~idx && this.observers.splice(idx, 1);
    }

    public notify(): void {
        this.observers.forEach(observer => {
            observer.update();
        });
    }
}

2.3 实现观察者类

// 实现观察者类
class Observer implements IObserver {
    constructor(private name: string) { }

    update(): void {
        console.log(`${this.name} has been notified.`);
    }
}

2.4 测试代码

function useObserver(){
    const subject: ISubject = new Subject();
    const Leo = new Observer("Leo");
    const Robin = new Observer("Robin");
    const Pual = new Observer("Pual");

    subject.addObserver(Leo);
    subject.addObserver(Robin);
    subject.addObserver(Pual);
    subject.notify();

    subject.removeObserver(Pual);
    subject.notify();
}

useObserver();
// [LOG]: "Leo has been notified." 
// [LOG]: "Robin has been notified." 
// [LOG]: "Pual has been notified." 
// [LOG]: "Leo has been notified." 
// [LOG]: "Robin has been notified." 

三、回顾 Object.defineProperty()

Vue.js 的数据响应式原理是基于 JS 标准内置对象方法 Object.defineProperty()方法来实现,该方法不兼容 IE8 和 FF22 及以下版本浏览器,这也是为什么 Vue.js 只能在这些版本之上的浏览器中才能运行的原因。

理解 Object.defineProperty() 对我们理解 Vue.js 响应式原理非常重要

Vue.js 3 使用 proxy 方法实现响应式,两者类似,我们只需搞懂Object.defineProperty()proxy 也就差不多理解了。

1. 概念介绍

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。
语法如下:

Object.defineProperty(obj, prop, descriptor)
  • 入参说明:

obj :要定义属性的源对象
prop :要定义或修改的属性名称Symbol
descriptor :要定义或修改的属性描述符,包括 configurableenumerablevaluewritablegetset,具体的可以去参阅文档

  • 出参说明:

修改后的源对象。

举个简单🌰例子:

const leo = {};
Object.defineProperty(leo, 'age', { 
    value: 18,
    writable: true
})
console.log(leo.age); // 18
leo.age = 22;
console.log(leo.age); // 22

2. 实现 getter/setter

我们知道 Object.defineProperty() 方法第三个参数是属性描述符(descriptor),支持设置 getset 描述符:

  • get 描述符:当访问该属性时,会调用此函数,默认值为 undefined ;
  • set 描述符:当修改该属性时,会调用此函数,默认值为 undefined
一旦对象拥有了 getter/setter 方法,我们可以简单将该对象称为响应式对象。

这两个操作符为我们提供拦截数据进行操作的可能性,修改前面示例,添加 getter/setter 方法:

let leo = {}, age = 18;
Object.defineProperty(leo, 'age', { 
    get(){
        // to do something
          console.log('监听到请求数据');
        return age;
    },
    set(newAge){
        // to do something
          console.log('监听到修改数据');
        age = newAge > age ? age : newAge
    }
})
leo.age = 20;  // 监听到修改数据
console.log(leo.age); // 监听到请求数据  // 18

leo.age = 10;  // 监听到修改数据
console.log(leo.age); // 监听到请求数据  // 10

访问 leo 对象的 age 属性,会通过 get 描述符处理,而修改 age 属性,则会通过 set 描述符处理。

四、实现简单的数据响应式

通过前面两个小节,我们复习了“观察者模式”和“Object.defineProperty()” 方法,这两个知识点在 Vue.js 响应式原理中非常重要。

接下来我们来实现一个很简单的数据响应式变化,需求如下:点击“更新数据”按钮,文本更新。

data-change.png

接下来我们将实现三个类:

  • Dep 被观察者类,用来生成被观察者;
  • Watcher 观察者类,用来生成观察者;
  • Observer 类,将普通数据转换为响应式数据,从而实现响应式对象

用一张图来描述三者之间关系,现在看不懂没关系,这小节看完可以再回顾这张图:
observer-watcher-dep.png

1. 实现精简观察者模式

这里参照前面复习“观察者模式”的示例,做下精简:

// 实现被观察者类
class Dep {
    constructor() {
        this.subs = [];
    }
    addSub(watcher) {
        this.subs.push(watcher);
    }
    notify(data) {
        this.subs.forEach(sub => sub.update(data));
    }
}
// 实现观察者类
class Watcher {
    constructor(cb) {
        this.cb = cb;
    }
    update(data) {
        this.cb(data);
    }
}

Vue.js 响应式原理中,观察者模式起到非常重要的作用。其中:

  • Dep 被观察者类,提供用来收集观察者( addSub )方法和通知观察者( notify )方法;
  • Watcher 观察者类,实例化时支持传入回调( cb )方法,并提供更新( update )方法;

2. 实现生成响应式的类

这一步需要实现 Observer 类,核心是通过 Object.defineProperty() 方法为对象的每个属性设置 getter/setter,目的是将普通数据转换为响应式数据,从而实现响应式对象

reactive-data.png

这里以最简单的单层对象为例(下一节会介绍深层对象),如:

let initData = {
    text: '你好,前端自习课',
    desc: '每日清晨,享受一篇前端优秀文章。'
};

接下来实现 Observer 类:

// 实现响应式类(最简单单层的对象,暂不考虑深层对象)
class Observer {
    constructor (node, data) {
        this.defineReactive(node, data)
    }

    // 实现数据劫持(核心方法)
    // 遍历 data 中所有的数据,都添加上 getter 和 setter 方法
    defineReactive(vm, obj) {
        //每一个属性都重新定义get、set
        for(let key in obj){
            let value = obj[key], dep = new Dep();
            Object.defineProperty(obj, key, {
                enumerable: true,
                configurable: true,
                get() {
                    // 创建观察者
                    let watcher = new Watcher(v => vm.innerText = v);
                    dep.addSub(watcher);
                    return value;
                },
                set(newValue) {
                    value = newValue;
                    // 通知所有观察者
                    dep.notify(newValue);
                }
            })
        }
    }
}

上面代码的核心是 defineReactive 方法,它遍历原始对象中每个属性,为每个属性实例化一个被观察者(Dep),然后分别调用 Object.defineProperty() 方法,为每个属性添加 getter/setter。

  • 访问数据时,getter 执行依赖收集(即添加观察者),通过实例化 Watcher 创建一个观察者,并执行被观察者的 addSub() 方法添加一个观察者;
  • 修改数据时,setter 执行派发更新(即通知观察者),通过调用被观察者的 notify() 方法通知所有观察者,执行观察者 update() 方法。

3. 测试代码

为了方便观察数据变化,我们为“更新数据”按钮绑定点击事件来修改数据:

<div id="app"></div>
<button id="update">更新数据</button>

测试代码如下:

// 初始化测试数据
let initData = {
    text: '你好,前端自习课',
    desc: '每日清晨,享受一篇前端优秀文章。'
};

const app = document.querySelector('#app');

// 步骤1:为测试数据转换为响应式对象
new Observer(app, initData);

// 步骤2:初始化页面文本内容
app.innerText = initData.text;

// 步骤3:绑定按钮事件,点击触发测试
document.querySelector('#update').addEventListener('click', function(){
    initData.text = `我们必须经常保持旧的记忆和新的希望。`;
    console.log(`当前时间:${new Date().toLocaleString()}`)
})

测试代码中,核心在于通过实例化 Observer,将测试数据转换为响应式数据,然后模拟数据变化,来观察视图变化。
每次点击“更新数据”按钮,在控制台中都能看到“数据发生变化!”的提示,说明我们已经能通过 setter 观察到数据的变化情况。

当然,你还可以在控制台手动修改 initData 对象中的 text 属性,来体验响应式变化~~

到这里,我们实现了非常简单的数据响应式变化,当然 Vue.js 肯定没有这么简单,这个先理解,下一节看 Vue.js 响应式原理,思路就会清晰很多。

这部分代码,我已经放到我的 Github,地址:https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Gist/Vue/Basics-Reactive-Demo.js

可以再回顾下这张图,对整个过程会更清晰:

observer-watcher-dep.png

五、Vue.js 响应式实现

本节代码:https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Gist/Vue/leo-vue-reactive/

这里大家可以再回顾下下面这张官网经典的图,思考下前面讲的示例。

(图片来自:https://cn.vuejs.org/v2/guide/reactivity.html

上一节实现了简单的数据响应式,接下来继续通过完善该示例,实现一个简单的 Vue.js 响应式,测试代码如下:

// index.js
const vm = new Vue({
    el: '#app',
    data(){
        return {
            text: '你好,前端自习课',
            desc: '每日清晨,享受一篇前端优秀文章。'
        }
    }
});

是不是很有内味了,下面是我们最终实现后项目目录:

- mini-reactive
    / index.html   // 入口 HTML 文件
  / index.js     // 入口 JS 文件
  / observer.js  // 实现响应式,将数据转换为响应式对象
  / watcher.js   // 实现观察者和被观察者(依赖收集者)
  / vue.js       // 实现 Vue 类作为主入口类
  / compile.js   // 实现编译模版功能

知道每一个文件功能以后,接下来将每一步串联起来。

1. 实现入口文件

我们首先实现入口文件,包括 index.html / index.js  2 个简单文件,用来方便接下来的测试。

1.1 index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <script data-original="./vue.js"></script>
    <script data-original="./observer.js"></script>
    <script data-original="./compile.js"></script>
    <script data-original="./watcher.js"></script>
</head>
<body>
    <div id="app">{{text}}</div>
    <button id="update">更新数据</button>
    <script data-original="./index.js"></script>
</body>
</html>

1.2 index.js

"use strict";
const vm = new Vue({
    el: '#app',
    data(){
        return {
            text: '你好,前端自习课',
            desc: '每日清晨,享受一篇前端优秀文章。'
        }
    }
});

console.log(vm.$data.text)
vm.$data.text = '页面数据更新成功!'; // 模拟数据变化
console.log(vm.$data.text)

2. 实现核心入口 vue.js

vue.js 文件是我们实现的整个响应式的入口文件,暴露一个 Vue 类,并挂载全局。

class Vue {
    constructor (options = {}) {
        this.$el = options.el;
        this.$data = options.data();
        this.$methods = options.methods;

        // [核心流程]将普通 data 对象转换为响应式对象
        new Observer(this.$data);

        if (this.$el) {
            // [核心流程]将解析模板的内容
            new Compile(this.$el, this)
        }
    }
}
window.Vue = Vue;

Vue 类入参为一个配置项 option ,使用起来跟 Vue.js 一样,包括 $el 挂载点、 $data 数据对象和 $methods 方法列表(本文不详细介绍)。

通过实例化 Oberser 类,将普通 data 对象转换为响应式对象,然后判断是否传入 el 参数,存在时,则实例化 Compile 类,解析模版内容。

总结下 Vue 这个类工作流程 :
vue-class.png

3. 实现 observer.js

observer.js 文件实现了 Observer 类,用来将普通对象转换为响应式对象:

class Observer {
    constructor (data) {
        this.data = data;
        this.walk(data);
    }

    // [核心方法]将 data 对象转换为响应式对象,为每个 data 属性设置 getter 和 setter 方法
    walk (data) {
        if (typeof data !== 'object') return data;
        Object.keys(data).forEach( key => {
            this.defineReactive(data, key, data[key])
        })
    }

    // [核心方法]实现数据劫持
    defineReactive (obj, key, value) {
        this.walk(value);  // [核心过程]遍历 walk 方法,处理深层对象。
        const dep = new Dep();
        Object.defineProperty(obj, key, {
            enumerable: true,
            configurable: true,
            get () {
                console.log('[getter]方法执行')
                Dep.target &&  dep.addSub(Dep.target);
                return value
            },
            set (newValue) {
                console.log('[setter]方法执行')
                if (value === newValue) return;
                // [核心过程]当设置的新值 newValue 为对象,则继续通过 walk 方法将其转换为响应式对象
                if (typeof newValue === 'object') this.walk(newValue);
                value = newValue;
                dep.notify(); // [核心过程]执行被观察者通知方法,通知所有观察者执行 update 更新
            }
        })
    }
}

相比较第四节实现的 Observer 类,这里做了调整:

  • 增加 walk 核心方法,用来遍历对象每个属性,分别调用数据劫持方法( defineReactive() );
  • defineReactive() 的 getter 中,判断 Dep.target 存在才添加观察者,下一节会详细介绍 Dep.target
  • defineReactive() 的 setter 中,判断当前新值( newValue )是否为对象,如果是,则直接调用 this.walk() 方法将当前对象再次转为响应式对象,处理深层对象

通过改善后的 Observer 类,我们就可以实现将单层或深层嵌套的普通对象转换为响应式对象

4. 实现 watcher.js

这里实现了 Dep 被观察者类(依赖收集者)和 Watcher 观察者类。

class Dep {
    constructor() {
        this.subs = [];
    }
    addSub(watcher) {
        this.subs.push(watcher);
    }
    notify(data) {
        this.subs.forEach(sub => sub.update(data));
    }
}

class Watcher {
    constructor (vm, key, cb) {
        this.vm = vm;   // vm:表示当前实例
        this.key = key; // key:表示当前操作的数据名称
        this.cb = cb;   // cb:表示数据发生改变之后的回调

        Dep.target = this; // 全局唯一
      
        // 此处通过 this.vm.$data[key] 读取属性值,触发 getter
        this.oldValue = this.vm.$data[key]; // 保存变化的数据作为旧值,后续作判断是否更新

        // 前面 getter 执行完后,执行下面清空
        Dep.target = null;
    }
    
    update () {
        console.log(`数据发生变化!`);
        let oldValue = this.oldValue;
        let newValue = this.vm.$data[this.key];
        if (oldValue != newValue) {  // 比较新旧值,发生变化才执行回调
            this.cb(newValue, oldValue);
        };
    }
}

相比较第四节实现的 Watcher  类,这里做了调整:

  • 在构造函数中,增加 Dep.target 值操作;
  • 在构造函数中,增加 oldValue 变量,保存变化的数据作为旧值,后续作为判断是否更新的依据;
  • update() 方法中,增加当前操作对象 key 对应值的新旧值比较,如果不同,才执行回调。

Dep.target当前全局唯一的订阅者,因为同一时间只允许一个订阅者被处理。target当前正在处理的目标订阅者,当前订阅者处理完就赋值为 null 。这里 Dep.target 会在 defineReactive() 的 getter 中使用到。

通过改善后的 Watcher 类,我们操作当前操作对象 key 对应值的时候,可以在数据有变化的情况才执行回调,减少资源浪费。

4. 实现 compile.js

compile.js 实现了 Vue.js 的模版编译,如将 HTML 中的 {{text}} 模版转换为具体变量的值。

compile.js 介绍内容较多,考虑到篇幅问题,并且本文核心介绍响应式原理,所以这里就暂时不介绍 compile.js 的实现,在学习的朋友可以到我 Github 上下载该文件直接下载使用即可,地址:
https://github.com/pingan8787/Leo-JavaScript/blob/master/Cute-Gist/Vue/leo-vue-reactive/compile.js

5. 测试代码

到这里,我们已经将第四节的 demo 改造成简易版 Vue.js 响应式,接下来打开 index.html 看看效果:

当 index.js 中执行到:

vm.$data.text = '我们必须经常保持旧的记忆和新的希望。';

页面便发生更新,页面显示的文本内容从“你好,前端自习课”更新成“我们必须经常保持旧的记忆和新的希望。”。

到这里,我们的简易版 Vue.js 响应式原理实现好了,能跟着文章看到这里的朋友,给你点个大大的赞👍

六、总结

本文首先通过回顾观察者模式和 Object.defineProperty() 方法,介绍 Vue.js 响应式原理的核心知识点,然后带大家通过一个简单示例实现简单响应式,最后通过改造这个简单响应式的示例,实现一个简单 Vue.js 响应式原理的示例。

相信看完本文的朋友,对 Vue.js 的响应式原理的理解会更深刻,希望大家理清思路,再好好回味下~

参考资料

  1. 官方文档 - 深入响应式原理 
  2. 《浅谈Vue响应式原理》
  3. 《Vue的数据响应式原理》 
查看原文

赞 11 收藏 7 评论 0

阿宝哥 发布了文章 · 1月13日

前端进阶不可错过的 10 个 Github 仓库

2021 年已经来了,相信有一些小伙伴已经开始立 2021 年的 flag 了。在 2020 年有一些小伙伴,私下问阿宝哥有没有前端的学习资料。为了统一回答这个问题,阿宝哥精心挑选了 Github 上 10 个不错的开源项目。

当然这 10 个项目不仅限于前端领域,希望这些项目对小伙伴的进阶能有所帮助。下面我们先来介绍第一个项目 —— build-your-own-x

build-your-own-x

🤓 Build your own (insert technology here)

https://github.com/danistefan...

WatchStarForkDate
3.5K92.3K8.1K2021-01-04

该仓库涉及了 27 个领域的内容,每个领域会使用特定的语言来实现某个功能。下图是与前端领域相关的内容:

关注「全栈修仙之路」阅读阿宝哥原创的 4 本免费电子书(累计下载2.2万+)及 50 几篇 “重学TS” 教程。

JavaScript Algorithms

📝 Algorithms and data structures implemented in JavaScript with explanations and links to further readings

https://github.com/trekhleb/j...

WatchStarForkDate
3.6K91.6K15.4K2021-01-04

该仓库包含了多种 基于 JavaScript 的算法与数据结构。每种算法和数据结构都有自己的 README,包含相关说明和链接,以便进一步阅读 (还有相关的视频) 。

30 Seconds of Code

Short JavaScript code snippets for all your development needs

https://github.com/30-seconds...

WatchStarForkDate
2K66.9K7.4K2021-01-04

该仓库包含了众多能满足你开发需求,简约的 JavaScript 代码片段。比如以下的 listenOnce 函数,可以保证事件处理器只执行一次。

const listenOnce = (el, evt, fn) => {
  let fired = false;
  el.addEventListener(evt, (e) => {
    if (!fired) fn(e);
    fired = true;
  });
};

listenOnce(
  document.getElementById('my-btn'),
  'click',
  () => console.log('Hello!')
);  // 'Hello!' will only be logged on the first click

Node Best Practices

✅ The Node.js best practices list

https://github.com/goldbergyo...

WatchStarForkDate
1.7K58.5K5.6K2021-01-04

该仓库介绍了 Node.js 应用的最佳实践,包含以下的内容:

RealWorld example apps

"The mother of all demo apps" — Exemplary fullstack Medium.com clone powered by React, Angular, Node, Django, and many more 🏅

https://github.com/gothinkste...

WatchStarForkDate
1.6K52.5K4.5K2021-01-04

对于大多数的 “Todo” 示例来说,它们只是简单介绍了框架的功能,并没有完整介绍使用该框架和相关技术栈,构建真正应用程序所需要的知识和视角。

RealWorld 解决了这个问题,它允许你选择任意前端框架(React,Vue 或 Angular 等)和任意后端框架(Node,Go,Spring 等)来驱动一个真实的、设计精美的全栈应用程序 “Conduit“ 。下图是目前已支持的前端框架(内容较多,只截取部分内容):

clean-code-javascript

🛁 Clean Code concepts adapted for JavaScript

https://github.com/ryanmcderm...

WatchStarForkDate
1.5K43.9K5.3K2021-01-04

该仓库介绍了如何写出整洁的 JavaScript 代码,比如作者建议使用可检索的名称:

不好的

// 86400000 的用途是什么?
setTimeout(blastOff, 86400000);

好的

// 使用通俗易懂的常量来描述该值
const MILLISECONDS_IN_A_DAY = 60 * 60 * 24 * 1000; //86400000;

setTimeout(blastOff, MILLISECONDS_IN_A_DAY);

该仓库包含了 11 个方面的内容,具体的目录如下图所示:

javascript-questions

A long list of (advanced) JavaScript questions, and their explanations ✨

https://github.com/lydiahalli...

WatchStarForkDate
85027K3.6K2021-01-04

该仓库包含了从基础到进阶的 JavaScript 知识,利用该仓库你可以测试你对 JavaScript 知识的掌握程度,也可以帮助你准备面试。

awesome-design-patterns

A curated list of software and architecture related design patterns.

https://github.com/DovAmir/aw...

WatchStarForkDate
47711.6K9312021-01-04

该仓库包含了软件与架构相关的设计模式的精选列表。在软件工程中,设计模式(Design Pattern)是对软件设计中普遍存在(反复出现)的各种问题,所提出的解决方案。这个术语是由埃里希·伽玛(Erich Gamma)等人在1990年代从建筑设计领域引入到计算机科学的。

developer-roadmap

Roadmap to becoming a web developer in 2021

https://github.com/kamranahme...

WatchStarForkDate
7.4K142K21.3K2021-01-04

该仓库包含一组图表,这些图表展示了成为一个 Web 开发者的学习路线图。该仓库含有前端、后端和 DevOps 的学习路线图,这里我们只介绍前端的学习路线图(原图是长图,这里只截取部分区域):

Free Programming Books

📚 Freely available programming books

https://github.com/EbookFound...

WatchStarForkDate
9.2K170K39.8K2021-01-04

该仓库包含了多种语言的免费学习资源列表,下图是中文免费资源列表(内容较多,只截取部分内容):

好的,到这里所有的开源项目都已经介绍完了,如果小伙伴有其他的不错的开源项目,欢迎给阿宝哥留言哟。

关注「全栈修仙之路」阅读阿宝哥原创的 4 本免费电子书(累计下载 2.2万+)及 9 篇源码分析系列教程。
查看原文

赞 37 收藏 24 评论 0

阿宝哥 赞了文章 · 1月1日

为了搞清楚类加载,竟然手撸JVM!


作者:小傅哥
博客:https://bugstack.cn
Github:https://github.com/fuzhengwei/CodeGuide/wiki

沉淀、分享、成长,让自己和他人都能有所收获!😄

一、前言

学习,不知道从哪下手?

当学习一个新知识不知道从哪下手的时候,最有效的办法是梳理这个知识结构的脉络信息,汇总出一整张的思维导出。接下来就是按照思维导图的知识结构,一个个学习相应的知识点,并汇总记录。

就像JVM的学习,可以说它包括了非常多的内容,也是一个庞大的知识体系。例如:类加载加载器生命周期性能优化调优参数调优工具优化方案内存区域虚拟机栈直接内存内存溢出元空间垃圾回收可达性分析标记清除回收过程等等。如果没有梳理的一头扎进去,东一榔头西一棒子,很容易造成学习恐惧感。

如图 24-1 是 JVM 知识框架梳理,后续我们会按照这个结构陆续讲解每一块内容。

图 24-1 JVM 知识框架

二、面试题

谢飞机,小记!,很多知识根本就是背背背,也没法操作,难学!

谢飞机:大哥,你问我两个JVM问题,我看看我自己还行不!

面试官:啊?嗯!往死了问还是?

谢飞机:就就就,都行!你看着来!

面试官:啊,那 JVM 加载过程都是什么步骤?

谢飞机:巴拉巴拉,加载、验证、准备、解析、初始化、使用、卸载!

面试官:嗯,背的挺好!我怀疑你没操作过! 那加载的时候,JVM 规范规定从第几位开始是解析常量池,以及数据类型是如何定义的,u1、u2、u4,是怎么个玩意?

谢飞机:握草!算了,告诉我看啥吧!

三、类加载过程描述

图 24-2 JVM 类加载过程

JVM 类加载过程分为加载链接初始化使用卸载这四个阶段,在链接中又包括:验证准备解析

  • 加载:Java 虚拟机规范对 class 文件格式进行了严格的规则,但对于从哪里加载 class 文件,却非常自由。Java 虚拟机实现可以从文件系统读取、从JAR(或ZIP)压缩包中提取 class 文件。除此之外也可以通过网络下载、数据库加载,甚至是运行时直接生成的 class 文件。
  • 链接:包括了三个阶段;

    • 验证,确保被加载类的正确性,验证字节流是否符合 class 文件规范,例魔数 0xCAFEBABE,以及版本号等。
    • 准备,为类的静态变量分配内存并设置变量初始值等
    • 解析,解析包括解析出常量池数据和属性表信息,这里会包括 ConstantPool 结构体以及 AttributeInfo 接口等。
  • 初始化:类加载完成的最后一步就是初始化,目的就是为标记常量值的字段赋值,以及执行 <clinit> 方法的过程。JVM虚拟机通过锁的方式确保 clinit 仅被执行一次
  • 使用:程序代码执行使用阶段。
  • 卸载:程序代码退出、异常、结束等。

四、写个代码加载下

JVM 之所以不好掌握,主要是因为不好实操。虚拟机是 C++ 写的,很多 Java 程序员根本就不会去读,或者读不懂。那么,也就没办法实实在在的体会到,到底是怎么加载的,加载的时候都干了啥。只有看到代码,我才觉得自己学会了!

所以,我们这里要手动写一下,JVM 虚拟机的部分代码,也就是类加载的过程。通过 Java 代码来实现 Java 虚拟机的部分功能,让开发 Java 代码的程序员更容易理解虚拟机的执行过程。

1. 案例工程

interview-24
├── pom.xml
└── src
    └── main
    │    └── java
    │        └── org.itstack.interview.jvm
    │             ├── classpath
    │             │   ├── impl
    │             │   │   ├── CompositeEntry.java
    │             │   │   ├── DirEntry.java 
    │             │   │   ├── WildcardEntry.java 
    │             │   │   └── ZipEntry.java    
    │             │   ├── Classpath.java
    │             │   └── Entry.java    
    │             ├── Cmd.java
    │             └── Main.java
    └── test
         └── java
             └── org.itstack.interview.jvm.test
                 └── HelloWorld.java

以上,工程结构就是按照 JVM 虚拟机规范,使用 Java 代码实现 JVM 中加载 class 文件部分内容。当然这部分还不包括解析,因为解析部分的代码非常庞大,我们先从把 .class 文件加载读取开始了解。

2. 代码讲解

2.1 定义类路径接口(Entry)

public interface Entry {

    byte[] readClass(String className) throws IOException;
    
    static Entry create(String path) {
        //File.pathSeparator;路径分隔符(win\linux)
        if (path.contains(File.pathSeparator)) {
            return new CompositeEntry(path);
        }
        if (path.endsWith("*")) {
            return new WildcardEntry(path);
        }
        if (path.endsWith(".jar") || path.endsWith(".JAR") ||
                path.endsWith(".zip") || path.endsWith(".ZIP")) {
            return new ZipEntry(path);
        }
        return new DirEntry(path);
    }
}
  • 接口中提供了接口方法 readClass 和静态方法 create(String path)
  • jdk1.8 是可以在接口中编写静态方法的,在设计上属于补全了抽象类的类似功能。这个静态方法主要是按照不同的路径地址类型,提供不同的解析方法。包括:CompositeEntry、WildcardEntry、ZipEntry、DirEntry,这四种。接下来分别看每一种的具体实现

2.2 目录形式路径(DirEntry)

public class DirEntry implements Entry {

    private Path absolutePath;

    public DirEntry(String path){
        //获取绝对路径
        this.absolutePath = Paths.get(path).toAbsolutePath();
    }

    @Override
    public byte[] readClass(String className) throws IOException {
        return Files.readAllBytes(absolutePath.resolve(className));
    }

    @Override
    public String toString() {
        return this.absolutePath.toString();
    }
}
  • 目录形式的通过读取绝对路径下的文件,通过 Files.readAllBytes 方式获取字节码。

2.3 压缩包形式路径(ZipEntry)

public class ZipEntry implements Entry {

    private Path absolutePath;

    public ZipEntry(String path) {
        //获取绝对路径
        this.absolutePath = Paths.get(path).toAbsolutePath();
    }

    @Override
    public byte[] readClass(String className) throws IOException {
        try (FileSystem zipFs = FileSystems.newFileSystem(absolutePath, null)) {
            return Files.readAllBytes(zipFs.getPath(className));
        }
    }

    @Override
    public String toString() {
        return this.absolutePath.toString();
    }

}
  • 其实压缩包形式与目录形式,只有在文件读取上有包装差别而已。FileSystems.newFileSystem

2.4 混合形式路径(CompositeEntry)

public class CompositeEntry implements Entry {

    private final List<Entry> entryList = new ArrayList<>();

    public CompositeEntry(String pathList) {
        String[] paths = pathList.split(File.pathSeparator);
        for (String path : paths) {
            entryList.add(Entry.create(path));
        }
    }

    @Override
    public byte[] readClass(String className) throws IOException {
        for (Entry entry : entryList) {
            try {
                return entry.readClass(className);
            } catch (Exception ignored) {
                //ignored
            }
        }
        throw new IOException("class not found " + className);
    }


    @Override
    public String toString() {
        String[] strs = new String[entryList.size()];
        for (int i = 0; i < entryList.size(); i++) {
            strs[i] = entryList.get(i).toString();
        }
        return String.join(File.pathSeparator, strs);
    }
    
}
  • File.pathSeparator,是一个分隔符属性,win/linux 有不同的类型,所以使用这个方法进行分割路径。
  • 分割后的路径装到 List 集合中,这个过程属于拆分路径。

2.5 通配符类型路径(WildcardEntry)

public class WildcardEntry extends CompositeEntry {

    public WildcardEntry(String path) {
        super(toPathList(path));
    }

    private static String toPathList(String wildcardPath) {
        String baseDir = wildcardPath.replace("*", ""); // remove *
        try {
            return Files.walk(Paths.get(baseDir))
                    .filter(Files::isRegularFile)
                    .map(Path::toString)
                    .filter(p -> p.endsWith(".jar") || p.endsWith(".JAR"))
                    .collect(Collectors.joining(File.pathSeparator));
        } catch (IOException e) {
            return "";
        }
    }

}
  • 这个类属于混合形式路径处理类的子类,唯一提供的方法就是把类路径解析出来。

2.6 类路径解析(Classpath)

启动类路径扩展类路径用户类路径,熟悉吗?是不经常看到这几句话,那么时候怎么实现的呢?

有了上面我们做的一些基础类的工作,接下来就是类解析的实际调用过程。代码如下:

public class Classpath {

    private Entry bootstrapClasspath;  //启动类路径
    private Entry extensionClasspath;  //扩展类路径
    private Entry userClasspath;       //用户类路径

    public Classpath(String jreOption, String cpOption) {
        //启动类&扩展类 "C:\Program Files\Java\jdk1.8.0_161\jre"
        bootstrapAndExtensionClasspath(jreOption);
        //用户类 F:\..\org\itstack\demo\test\HelloWorld
        parseUserClasspath(cpOption);
    }

    private void bootstrapAndExtensionClasspath(String jreOption) {
        
        String jreDir = getJreDir(jreOption);

        //..jre/lib/*
        String jreLibPath = Paths.get(jreDir, "lib") + File.separator + "*";
        bootstrapClasspath = new WildcardEntry(jreLibPath);

        //..jre/lib/ext/*
        String jreExtPath = Paths.get(jreDir, "lib", "ext") + File.separator + "*";
        extensionClasspath = new WildcardEntry(jreExtPath);

    }

    private static String getJreDir(String jreOption) {
        if (jreOption != null && Files.exists(Paths.get(jreOption))) {
            return jreOption;
        }
        if (Files.exists(Paths.get("./jre"))) {
            return "./jre";
        }
        String jh = System.getenv("JAVA_HOME");
        if (jh != null) {
            return Paths.get(jh, "jre").toString();
        }
        throw new RuntimeException("Can not find JRE folder!");
    }

    private void parseUserClasspath(String cpOption) {
        if (cpOption == null) {
            cpOption = ".";
        }
        userClasspath = Entry.create(cpOption);
    }

    public byte[] readClass(String className) throws Exception {
        className = className + ".class";

        //[readClass]启动类路径
        try {
            return bootstrapClasspath.readClass(className);
        } catch (Exception ignored) {
            //ignored
        }

        //[readClass]扩展类路径
        try {
            return extensionClasspath.readClass(className);
        } catch (Exception ignored) {
            //ignored
        }

        //[readClass]用户类路径
        return userClasspath.readClass(className);
    }

}
  • 启动类路径,bootstrapClasspath.readClass(className);
  • 扩展类路径,extensionClasspath.readClass(className);
  • 用户类路径,userClasspath.readClass(className);
  • 这回就看到它们具体在哪使用了吧!有了具体的代码也就方便理解了

2.7 加载类测试验证

private static void startJVM(Cmd cmd) {
    Classpath cp = new Classpath(cmd.jre, cmd.classpath);
    System.out.printf("classpath:%s class:%s args:%s\n", cp, cmd.getMainClass(), cmd.getAppArgs());
    //获取className
    String className = cmd.getMainClass().replace(".", "/");
    try {
        byte[] classData = cp.readClass(className);
        System.out.println(Arrays.toString(classData));
    } catch (Exception e) {
        System.out.println("Could not find or load main class " + cmd.getMainClass());
        e.printStackTrace();
    }
}

这段就是使用 Classpath 类进行类路径加载,这里我们测试加载 java.lang.String 类。你可以加载其他的类,或者自己写的类

  • 配置IDEA,program arguments 参数:-Xjre "C:\Program Files\Java\jdk1.8.0_161\jre" java.lang.String
  • 另外这里读取出的 class 文件信息,打印的是 byte 类型信息。

测试结果

[-54, -2, -70, -66, 0, 0, 0, 52, 2, 28, 3, 0, 0, -40, 0, 3, 0, 0, -37, -1, 3, 0, 0, -33, -1, 3, 0, 1, 0, 0, 8, 0, 15, 8, 0, 61, 8, 0, 85, 8, 0, 88, 8, 0, 89, 8, 0, 112, 8, 0, -81, 8, 0, -75, 8, 0, -47, 8, 0, -45, 1, 0, 0, 1, 0, 3, 40, 41, 73, 1, 0, 20, 40, 41, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 79, 98, 106, 101, 99, 116, 59, 1, 0, 20, 40, 41, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 83, 116, 114, 105, 110, 103, 59, 1, 0, 3, 40, 41, 86, 1, 0, 3, 40, 41, 90, 1, 0, 4, 40, 41, 91, ...]

这块部分截取的程序运行打印结果,就是读取的 class 文件信息,只不过暂时还不能看出什么。接下来我们再把它翻译过来!

五、解析字节码文件

JVM 在把 class 文件加载完成后,接下来就进入链接的过程,这个过程包括了内容的校验、准备和解析,其实就是把 byte 类型 class 翻译过来,做相应的操作。

整个这个过程内容相对较多,这里只做部分逻辑的实现和讲解。如果读者感兴趣可以阅读小傅哥的《用Java实现JVM》专栏。

1. 提取部分字节码

//取部分字节码:java.lang.String
private static byte[] classData = {
        -54, -2, -70, -66, 0, 0, 0, 52, 2, 26, 3, 0, 0, -40, 0, 3, 0, 0, -37, -1, 3, 0, 0, -33, -1, 3, 0, 1, 0, 0, 8, 0,
        59, 8, 0, 83, 8, 0, 86, 8, 0, 87, 8, 0, 110, 8, 0, -83, 8, 0, -77, 8, 0, -49, 8, 0, -47, 1, 0, 3, 40, 41, 73, 1,
        0, 20, 40, 41, 76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 79, 98, 106, 101, 99, 116, 59, 1, 0, 20, 40, 41,
        76, 106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 83, 116, 114, 105, 110, 103, 59, 1, 0, 3, 40, 41, 86, 1, 0, 3,
        40, 41, 90, 1, 0, 4, 40, 41, 91, 66, 1, 0, 4, 40, 41, 91, 67, 1, 0, 4, 40, 67, 41, 67, 1, 0, 21, 40, 68, 41, 76,
        106, 97, 118, 97, 47, 108, 97, 110, 103, 47, 83, 116, 114, 105, 110, 103, 59, 1, 0, 4, 40, 73, 41, 67, 1, 0, 4};
  • java.lang.String 解析出来的字节码内容较多,当然包括的内容也多,比如魔数、版本、类、常量、方法等等。所以我们这里只截取部分进行进行解析。

2. 解析魔数并校验

很多文件格式都会规定满足该格式的文件必须以某几个固定字节开头,这几个字节主要起到标识作用,叫作魔数(magic number)。

例如;

  • PDF文件以4字节“%PDF”(0x25、0x50、0x44、0x46)开头,
  • ZIP文件以2字节“PK”(0x50、0x4B)开头
  • class文件以4字节“0xCAFEBABE”开头
private static void readAndCheckMagic() {
    System.out.println("\r\n------------ 校验魔数 ------------");
    //从class字节码中读取前四位
    byte[] magic_byte = new byte[4];
    System.arraycopy(classData, 0, magic_byte, 0, 4);
    
    //将4位byte字节转成16进制字符串
    String magic_hex_str = new BigInteger(1, magic_byte).toString(16);
    System.out.println("magic_hex_str:" + magic_hex_str);
    
    //byte_magic_str 是16进制的字符串,cafebabe,因为java中没有无符号整型,所以如果想要无符号只能放到更高位中
    long magic_unsigned_int32 = Long.parseLong(magic_hex_str, 16);
    System.out.println("magic_unsigned_int32:" + magic_unsigned_int32);
    
    //魔数比对,一种通过字符串比对,另外一种使用假设的无符号16进制比较。如果使用无符号比较需要将0xCAFEBABE & 0x0FFFFFFFFL与运算
    System.out.println("0xCAFEBABE & 0x0FFFFFFFFL:" + (0xCAFEBABE & 0x0FFFFFFFFL));
    
    if (magic_unsigned_int32 == (0xCAFEBABE & 0x0FFFFFFFFL)) {
        System.out.println("class字节码魔数无符号16进制数值一致校验通过");
    } else {
        System.out.println("class字节码魔数无符号16进制数值一致校验拒绝");
    }
}
  • 读取字节码中的前四位,-54, -2, -70, -66,将这四位转换为16进制。
  • 因为 java 中是没有无符号整型的,所以只能用更高位存放。
  • 解析后就是魔数的对比,看是否与 CAFEBABE 一致。

测试结果

------------ 校验魔数 ------------
magic_hex_str:cafebabe
magic_unsigned_int32:3405691582
0xCAFEBABE & 0x0FFFFFFFFL:3405691582
class字节码魔数无符号16进制数值一致校验通过

3. 解析版本号信息

刚才我们已经读取了4位魔数信息,接下来再读取2位,是版本信息。

魔数之后是class文件的次版本号和主版本号,都是u2类型。假设某class文件的主版本号是M,次版本号是m,那么完整的版本号可以表示成“M.m”的形式。次版本号只在J2SE 1.2之前用过,从1.2开始基本上就没有什么用了(都是0)。主版本号在J2SE 1.2之前是45,从1.2开始,每次有大版本的Java版本发布,都会加1{45、46、47、48、49、50、51、52}

private static void readAndCheckVersion() {
    System.out.println("\r\n------------ 校验版本号 ------------");
    //从class字节码第4位开始读取,读取2位
    byte[] minor_byte = new byte[2];
    System.arraycopy(classData, 4, minor_byte, 0, 2);
    
    //将2位byte字节转成16进制字符串
    String minor_hex_str = new BigInteger(1, minor_byte).toString(16);
    System.out.println("minor_hex_str:" + minor_hex_str);
    
    //minor_unsigned_int32 转成无符号16进制
    int minor_unsigned_int32 = Integer.parseInt(minor_hex_str, 16);
    System.out.println("minor_unsigned_int32:" + minor_unsigned_int32);
    
    //从class字节码第6位开始读取,读取2位
    byte[] major_byte = new byte[2];
    System.arraycopy(classData, 6, major_byte, 0, 2);
    
    //将2位byte字节转成16进制字符串
    String major_hex_str = new BigInteger(1, major_byte).toString(16);
    System.out.println("major_hex_str:" + major_hex_str);
    
    //major_unsigned_int32 转成无符号16进制
    int major_unsigned_int32 = Integer.parseInt(major_hex_str, 16);
    System.out.println("major_unsigned_int32:" + major_unsigned_int32);
    System.out.println("版本号:" + major_unsigned_int32 + "." + minor_unsigned_int32);
}
  • 这里有一个小技巧,class 文件解析出来是一整片的内容,JVM 需要按照虚拟机规范,一段一段的解析出所有的信息。
  • 同样这里我们需要把2位byte转换为16进制信息,并继续从第6位继续读取2位信息。组合出来的才是版本信息。

测试结果

------------ 校验版本号 ------------
minor_hex_str:0
minor_unsigned_int32:0
major_hex_str:34
major_unsigned_int32:52
版本号:52.0

4. 解析全部内容对照

按照 JVM 的加载过程,其实远不止魔数和版本号信息,还有很多其他内容,这里我们可以把测试结果展示出来,方便大家有一个学习结果的比对印象。

classpath:org.itstack.demo.jvm.classpath.Classpath@4bf558aa class:java.lang.String args:null
version: 52.0
constants count:540
access flags:0x31
this class:java/lang/String
super class:java/lang/Object
interfaces:[java/io/Serializable, java/lang/Comparable, java/lang/CharSequence]
fields count:5
value          [C
hash          I
serialVersionUID          J
serialPersistentFields          [Ljava/io/ObjectStreamField;
CASE_INSENSITIVE_ORDER          Ljava/util/Comparator;
methods count: 94
<init>          ()V
<init>          (Ljava/lang/String;)V
<init>          ([C)V
<init>          ([CII)V
<init>          ([III)V
<init>          ([BIII)V
<init>          ([BI)V
checkBounds          ([BII)V
<init>          ([BIILjava/lang/String;)V
<init>          ([BIILjava/nio/charset/Charset;)V
<init>          ([BLjava/lang/String;)V
<init>          ([BLjava/nio/charset/Charset;)V
<init>          ([BII)V
<init>          ([B)V
<init>          (Ljava/lang/StringBuffer;)V
<init>          (Ljava/lang/StringBuilder;)V
<init>          ([CZ)V
length          ()I
isEmpty          ()Z
charAt          (I)C
codePointAt          (I)I
codePointBefore          (I)I
codePointCount          (II)I
offsetByCodePoints          (II)I
getChars          ([CI)V
getChars          (II[CI)V
getBytes          (II[BI)V
getBytes          (Ljava/lang/String;)[B
getBytes          (Ljava/nio/charset/Charset;)[B
getBytes          ()[B
equals          (Ljava/lang/Object;)Z
contentEquals          (Ljava/lang/StringBuffer;)Z
nonSyncContentEquals          (Ljava/lang/AbstractStringBuilder;)Z
contentEquals          (Ljava/lang/CharSequence;)Z
equalsIgnoreCase          (Ljava/lang/String;)Z
compareTo          (Ljava/lang/String;)I
compareToIgnoreCase          (Ljava/lang/String;)I
regionMatches          (ILjava/lang/String;II)Z
regionMatches          (ZILjava/lang/String;II)Z
startsWith          (Ljava/lang/String;I)Z
startsWith          (Ljava/lang/String;)Z
endsWith          (Ljava/lang/String;)Z
hashCode          ()I
indexOf          (I)I
indexOf          (II)I
indexOfSupplementary          (II)I
lastIndexOf          (I)I
lastIndexOf          (II)I
lastIndexOfSupplementary          (II)I
indexOf          (Ljava/lang/String;)I
indexOf          (Ljava/lang/String;I)I
indexOf          ([CIILjava/lang/String;I)I
indexOf          ([CII[CIII)I
lastIndexOf          (Ljava/lang/String;)I
lastIndexOf          (Ljava/lang/String;I)I
lastIndexOf          ([CIILjava/lang/String;I)I
lastIndexOf          ([CII[CIII)I
substring          (I)Ljava/lang/String;
substring          (II)Ljava/lang/String;
subSequence          (II)Ljava/lang/CharSequence;
concat          (Ljava/lang/String;)Ljava/lang/String;
replace          (CC)Ljava/lang/String;
matches          (Ljava/lang/String;)Z
contains          (Ljava/lang/CharSequence;)Z
replaceFirst          (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
replaceAll          (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;
replace          (Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Ljava/lang/String;
split          (Ljava/lang/String;I)[Ljava/lang/String;
split          (Ljava/lang/String;)[Ljava/lang/String;
join          (Ljava/lang/CharSequence;[Ljava/lang/CharSequence;)Ljava/lang/String;
join          (Ljava/lang/CharSequence;Ljava/lang/Iterable;)Ljava/lang/String;
toLowerCase          (Ljava/util/Locale;)Ljava/lang/String;
toLowerCase          ()Ljava/lang/String;
toUpperCase          (Ljava/util/Locale;)Ljava/lang/String;
toUpperCase          ()Ljava/lang/String;
trim          ()Ljava/lang/String;
toString          ()Ljava/lang/String;
toCharArray          ()[C
format          (Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;
format          (Ljava/util/Locale;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;
valueOf          (Ljava/lang/Object;)Ljava/lang/String;
valueOf          ([C)Ljava/lang/String;
valueOf          ([CII)Ljava/lang/String;
copyValueOf          ([CII)Ljava/lang/String;
copyValueOf          ([C)Ljava/lang/String;
valueOf          (Z)Ljava/lang/String;
valueOf          (C)Ljava/lang/String;
valueOf          (I)Ljava/lang/String;
valueOf          (J)Ljava/lang/String;
valueOf          (F)Ljava/lang/String;
valueOf          (D)Ljava/lang/String;
intern          ()Ljava/lang/String;
compareTo          (Ljava/lang/Object;)I
<clinit>          ()V

Process finished with exit code 0

六、总结

  • 学习 JVM 最大的问题是不好实践,所以本文以案例实操的方式,学习 JVM 的加载解析过程。也让更多的对 JVM 感兴趣的研发,能更好的接触到 JVM 并深入的学习。
  • 有了以上这段代码,大家可以参照 JVM 虚拟机规范,在调试Java版本的JVM,这样就可以非常容易理解整个JVM的加载过程,都做了什么。
  • 如果大家需要文章中一些原图 xmind 或者源码,可以添加作者小傅哥(fustack),或者关注公众号:bugstack虫洞栈进行获取。好了,本章节就扯到这,后续还有很多努力,持续原创,感谢大家的支持!

七、系列推荐

查看原文

赞 10 收藏 7 评论 0

阿宝哥 赞了文章 · 2020-12-29

2020 总结 | 21 张图总结我的 2020 年

海沧湾公园.png

生活不可能像你想象的那么好,但也不会像你想象的那么糟。我觉得人的脆弱和坚强都超乎自己的想像,有时我脆弱得一句话就泪流满面,有时又发现自己咬着牙走了很长的路。

回看 2020,我更加喜爱这句话了,每个小句子都有了不同味道。

一、再见 2020 👋

2020疫情复工后,我便开始进入“战斗模式”,深受公众号“全栈修仙之路”作者“阿宝哥”影响🌟,开始把更多时间和精力用来修炼自身,努力成长和进阶,成为一位“靠谱的人”和一名“T 型人才”。

  • 靠谱的人:让自己靠谱,让别人放心;
  • T 型人才:深挖知识深度,拓展知识广度。

(以下数据统计的时间,全部以 2020-12-19 日截止)

🤗🤗🤗

1. 走过的路

疫情期间为了不给国家添麻烦,咱就天天家里窝着,闲来无事就给“貔貅”拍拍照啥的😎。

貔貅

复工后,骑上我的“绿豆”去了好多地方(快把厦门岛逛透透了),算是把疫情期间没去玩的地方都补上了🤠。

骑行.png

还有这杯意义不同的咖啡,和一句“心之所在即为家”😜。

星巴克的味道

当然,除了玩,这一年也做了很多重要的事情😊。

2020 年,写了很多文章(包含未发布),基本都放在语雀上。数一数,将近 150 篇文档是在 2020 年完成的🤔。

语雀

除了文章,我也画了很多图,慢慢形成自己的画图风格。

画图

当然,代码还是少不了的😎。

程序员嘛,当然要看看代码提交次数,这一年提交了这些代码,感觉 Github + Gitlab + Gitee 三个提交记录合并一下,都快铺满了。

  1. Github 提交记录

gitlab 提交记录.png

  1. Gitlab 提交记录

gitlab 提交记录.png

  1. Gitee(码云) 提交记录

gitlab 提交记录.png

另外自己的微信公众号“前端自习课”也完成“连续推送 810+ 天”的成绩,这一年,我也玩起短视频,视频号了,也开始自己做动画,将一些知识点通过动画和大家分享,视频可以查看《1分钟了解 Axios 拦截器实现原理》

1分钟了解 Axios 拦截器实现原理

2020 这一年还有很多事情想和大家分享,考虑到本文主旨和内容篇幅,就不再多介绍咯~有兴趣的朋友欢迎私聊我(微信:pingan8787)💘。

2. 感谢的人

今年最需要感谢的,是阿宝哥和我们“前端突击队学习小组的每位小伙伴啦💐~

🌰最大感受是:原来前端还能这样玩!
🌰最开心的是:团队学习更有动力,你不是一个人在战斗!

在阿宝哥指导下,整理了一份自己的前端技能树,才知道自己的前端技能有几斤几两重,也才有更多动力和更清晰的方向。

前端技能树

在我们学习小组中,采用“专题学习 + 总结输出”的方式一起学习,目前已经沉淀 200+ 篇文章啦!
小组目前 7 人(不含班主任),平均下来每人将近写了 30+ 篇!为小伙伴们点赞👍~

学习小组

2020 年 11 月的某一天,思考了最近学习的知识和接下来的需要做的事情,于是有了下面的这篇字数少,内容多的笔记(用手机敲的,就是有点手酸🙁):

学习总结

慢慢的,越来越发现,学得越多,发现自己要学的越多。🤣

这里再次感谢阿宝哥,感谢“前端突击队”的小伙伴们。未来继续冲🦆!

3. 遗憾的事

这一年,比较遗憾的事,是自己与阿里插肩而过呀🥺~倒也让我发现更多不足。

遗憾的事

这里也非常感谢内推的小伙伴,还有几位面试官,人都挺不错。😃

我们闽南人嘛,喜欢“爱拼才会赢”,所以,趁年轻多拼多创。

4. 点赞的事

这一年为自己坚持的几件事情点赞~
①自己微信公众号“前端自习课”连续推送 810+ 天文章,为此我把所有文章分类做了一张词云图,如下:

前端自习课

可以看出,我主要分享的内容包括:“JS”、“CSS”、“拓展”和“Web技术”。🔔

②自己坚持的每月学习文章整理,也超过 40+ 个月了,截图如下:

详细请看 github 地址:https://github.com/pingan8787/Leo_Reading

github 学习记录

二、你好 2021 👏

2021 年即将到来,希望新的一年,每一个“下次一定”都能实现完成承诺。

⚽️⚽️⚽️

1. 加油,前端工程师

在这前端生涯的第五年伊始,回想自己踩过的坑,走过的弯路,才慢慢领悟自己的前端生涯应该如何去走。

曾经和多数人一样,时常迷失学习什么知识,看到什么火,就去学什么,到头来,效果并不好。

未来自己的前端生涯,更应该站在巨人肩膀上,看向更远的地方。定个小目标呗,早日晋升技术专家。

接下来的时间里,做好自己在工作中的身份,做一个优秀的前端工程师。

桌面

2. 加油,小儿子

作为家中最小的孩子,被催婚已经成为这一年的常事,哈哈。

也许性格如此,加上独自在外工作,每天只想把事情做得更好,学更多知识,提升自己的价值。

很幸运这一年遇到了女孩 C。

接下来的时间里,做好自己在家里的身份,做一个让父母放心的好儿子。

五店市.png

3. 加油,骑行侠

我这人,兴趣爱好不太多,比如:骑行🚴、足球⚽️、敲代码💻。

骑行让我如此着迷。

换上衣服,12 月的寒风,也依然无法阻挡我的脚步。

接下来的时间里,坚持自己的热爱,做一个勇往直前大胆创的闽南人。

寒风.png

4. 加油,前端自习课

运营公众号“前端自习课”以后,认识了许多小伙伴,看见了许多从前的自己。

后来也慢慢和大家分享一些自己的经验和经历。

深刻记得,我简历中最后一句话:“希望自己的成⻓之路能帮助更多人,也希望在这个世界留下自己的一些足迹”。

接下来的时间里,坚持自己的初心,做一个对这个社区、这个社会有帮助的人。
zhihu.png

三、总结

每一年的总结,都是五味杂陈,才发现这一年来,自己又进步和成长了。

回顾篇头的一句话:“有时又发现自己咬着牙走了很长的路”。有时候一瞬间,一个偶然,发现自己原来咬着牙前进这么久,改变这么多。

最后,再思考一句话,希望对大家能有不同感受:

除去睡眠,人的一生有一万多天。但是人与人之间的区别就在于,你究竟是活了一万多天,还是仅仅活了一天,却重复了一万多次。

希望未来的我们,会感到自己的每一天都是崭新的。
武磊
像武磊一样努力,加油!

最后欢迎关注我呀~

本文参与了 SegmentFault 思否征文「2020 总结」,欢迎正在阅读的你也加入。
查看原文

赞 18 收藏 4 评论 11

阿宝哥 关注了用户 · 2020-12-24

dongzhe3917875 @dongzhe3917875

关注 39

阿宝哥 发布了文章 · 2020-12-24

想要复制图像?Clipboard API 了解一下

在写了 这个 29.7 K 的剪贴板 JS 库有点东西! 这篇文章之后,收到了小伙伴提的两个问题:

1.clipboard.js 这个库除了复制文字之外,能复制图像么?

2.clipboard.js 这个库依赖的 document.execCommand API 已被废弃了,以后应该怎么办?

(图片来源:https://developer.mozilla.org...

接下来,本文将围绕上述两个问题展开,不过在看第一个问题之前,我们先来简单介绍一下 剪贴板 📋。

剪贴板(英语:clipboard),有时也称剪切板、剪贴簿、剪贴本。它是一种软件功能,通常由操作系统提供,作用是使用复制和粘贴操作短期存储数据和在文档或应用程序间转移数据。它是图形用户界面(GUI)环境中最常用的功能之一,通常实现为匿名、临时的数据缓冲区,可以被环境内的大部分或所有程序使用编程接口访问。 —— 维基百科

通过以上的描述我们可以知道,剪贴板架起了一座桥梁,使得在各种应用程序之间,传递和共享信息成为可能。然而美中不足的是,剪贴板只能保留一份数据,每当新的数据传入,旧的便会被覆盖。

了解完 剪贴板 📋 的概念和作用之后,我们马上来看一下第一个问题:clipboard.js 这个库除了复制文字之外,能复制图像么?

关注「全栈修仙之路」阅读阿宝哥原创的 4 本免费电子书(累计下载近2.1万)及 50 几篇 “重学TS” 教程。

一、clipboard.js 能否复制图像?

clipboard.js 是一个用于将 文本 复制到剪贴板的 JS 库。没有使用 Flash,没有使用任何框架,开启 gzipped 压缩后仅仅只有 3kb

(图片来源:https://clipboardjs.com/#exam...

当你看到 “A modern approach to copy text to clipboard” 这个描述,你是不是已经知道答案了。那么实际的情况是怎样呢?下面我们来动手验证一下。在 这个 29.7 K 的剪贴板 JS 库有点东西! 这篇文章中,阿宝哥介绍了在实例化 ClipboardJS 对象时,可以通过 options 对象的 target 属性来设置复制的目标:

// https://github.com/zenorocha/clipboard.js/blob/master/demo/function-target.html
let clipboard = new ClipboardJS('.btn', {
  target: function() {
    return document.querySelector('div');
  }
});

利用 clipboard.js 的这个特性,我们可以定义以下 HTML 结构:

<div id="container">
   <img data-original="http://cdn.semlinker.com/abao.png" width="80" height="80"/>
   <p>大家好,我是阿宝哥</p>
</div>
<button class="btn">复制</button>

然后在实例化 ClipboardJS 对象时设置复制的目标是 #container 元素:

const clipboard = new ClipboardJS(".btn", {
  target: function () {
    return document.querySelector("#container");
  }
});

之后,我们点击页面中的 复制 按钮,对应的效果如下图所示:

观察上图可知,页面中的图像和文本都已经被复制了。对于文本来说,大家应该都很清楚。而对于图像来说,到底复制了什么?我们又该如何获取已复制的内容呢?针对这个问题,我们可以利用 HTMLElement 对象上的 onpaste 属性或者监听元素上的 paste 事件。

这里我们通过设置 document 对象的 onpaste 属性,来打印一下粘贴事件对应的事件对象:

document.onpaste = function (e) {
  console.dir(e);
}

当我们点击 复制 按钮,然后在页面执行 粘贴 操作后,控制台会打印出以下内容:

通过上图可知,在 ClipboardEvent 对象中含有一个 clipboardData 属性,该属性包含了与剪贴板相关联的数据。详细分析了 clipboardData 属性之后,我们发现已复制的图像和普通文本被封装为 DataTransferItem 对象。

为了更方便地分析 DataTransferItem 对象,阿宝哥重新更新了 document 对象的 onpaste 属性:

在上图中,我们可以清楚的看到 DataTransferItem 对象上含有 kindtype 属性分别用于表示数据项的类型(string 或 file)及数据对应的 MIME 类型。利用 DataTransferItem 对象提供的 getAsString 方法,我们可以获取该对象中保存的数据:

相信看完以上的输出结果,小伙伴们就很清楚第一个问题的答案了。那么如果想要复制图像的话,应该如何实现呢?其实这个问题的答案与小伙伴提的第二个问题的答案是一样的,我们可以利用 Clipboard API 来实现复制图像的问题及解决 document.execCommand API 已被废弃的问题。

接下来,我们的目标就是实现复制图像的功能了,因为要利用到 Clipboard API,所以阿宝哥先来介绍一下该 API。

二、Clipboard API 简介

Clipboard 接口实现了 Clipboard API,如果用户授予了相应的权限,就能提供系统剪贴板的读写访问。在 Web 应用程序中,Clipboard API 可用于实现剪切、复制和粘贴功能。该 API 用于取代通过 document.execCommand API 来实现剪贴板的操作。

在实际项目中,我们不需要手动创建 Clipboard 对象,而是通过 navigator.clipboard 来获取 Clipboard 对象:

在获取 Clipboard 对象之后,我们就可以利用该对象提供的 API 来访问剪贴板,比如:

navigator.clipboard.readText().then(
  clipText => document.querySelector(".editor").innerText = clipText);

以上代码将 HTML 中含有 .editor 类的第一个元素的内容替换为剪贴板的内容。如果剪贴板为空,或者不包含任何文本,则元素的内容将被清空。这是因为在剪贴板为空或者不包含文本时,readText 方法会返回一个空字符串。

在继续介绍 Clipboard API 之前,我们先来看一下 Navigator API: clipboard 的兼容性:

(图片来源:https://caniuse.com/mdn-api_n...

异步剪贴板 API 是一个相对较新的 API,浏览器仍在逐渐实现它。由于潜在的安全问题和技术复杂性,大多数浏览器正在逐步集成这个 API。对于浏览器扩展来说,你可以请求 clipboardRead 和 clipboardWrite 权限以使用 clipboard.readText() 和 clipboard.writeText()。

好的,接下来阿宝哥来演示一下如何使用 clipboard 对象提供的 API 来操作剪贴板,以下示例的运行环境是 Chrome 87.0.4280.88

三、将数据写入到剪贴板

3.1 writeText()

writeText 方法可以把指定的字符串写入到系统的剪贴板中,调用该方法后会返回一个 Promise 对象:

<button onclick="copyPageUrl()">拷贝当前页面地址</button>
<script>
   async function copyPageUrl() {
     try {
       await navigator.clipboard.writeText(location.href);
       console.log("页面地址已经被拷贝到剪贴板中");
     } catch (err) {
       console.error("页面地址拷贝失败: ", err);
     }
  }
</script>

对于上述代码,当用户点击 拷贝当前页面地址 按钮时,将会把当前的页面地址拷贝到剪贴板中。

3.2 write()

write 方法除了支持文本数据之外,还支持将图像数据写入到剪贴板,调用该方法后会返回一个 Promise 对象。

<button onclick="copyPageUrl()">拷贝当前页面地址</button>
<script>
   async function copyPageUrl() {
     const text = new Blob([location.href], {type: 'text/plain'});
     try {
       await navigator.clipboard.write(
         new ClipboardItem({
           "text/plain": text,
         }),
       );
       console.log("页面地址已经被拷贝到剪贴板中");
     } catch (err) {
       console.error("页面地址拷贝失败: ", err);
     }
  }
</script>

在以上代码中,我们先通过 Blob API 创建 Blob 对象,然后使用该 Blob 对象来构造 ClipboardItem 对象,最后再通过 write 方法把数据写入到剪贴板。介绍完如何将数据写入到剪贴板,下面我们来介绍如何从剪贴板中读取数据。

对 Blob API 感兴趣的小伙伴,可以阅读 你不知道的 Blob 这篇文章。

四、从剪贴板中读取数据

4.1 readText()

readText 方法用于读取剪贴板中的文本内容,调用该方法后会返回一个 Promise 对象:

<button onclick="getClipboardContents()">读取剪贴板中的文本</button>
<script>
   async function getClipboardContents() {
     try {
       const text = await navigator.clipboard.readText();
       console.log("已读取剪贴板中的内容:", text);
     } catch (err) {
       console.error("读取剪贴板内容失败: ", err);
     }
   }
</script>

对于上述代码,当用户点击 读取剪贴板中的文本 按钮时,如果当前剪贴板含有文本内容,则会读取剪贴板中的文本内容。

4.2 read()

read 方法除了支持读取文本数据之外,还支持读取剪贴板中的图像数据,调用该方法后会返回一个 Promise 对象:

<button onclick="getClipboardContents()">读取剪贴板中的内容</button>
<script>
async function getClipboardContents() {
  try {
    const clipboardItems = await navigator.clipboard.read();
    for (const clipboardItem of clipboardItems) {
      for (const type of clipboardItem.types) {
        const blob = await clipboardItem.getType(type);
        console.log("已读取剪贴板中的内容:", await blob.text());
      }
    }
  } catch (err) {
      console.error("读取剪贴板内容失败: ", err);
    }
  }
</script>

对于上述代码,当用户点击 读取剪贴板中的内容 按钮时,则会开始读取剪贴板中的内容。到这里 clipboard 对象中涉及的 4 个 API,阿宝哥都已经介绍完了,最后我们来看一下如何实现复制图像的功能。

五、实现复制图像的功能

在最后的这个示例中,阿宝哥将跟大家一步步实现复制图像的核心功能,除了复制图像之外,还会同时支持复制文本。在看具体代码前,我们先来看一下实际的效果:

在上图对应的网页中,我们先点击 复制 按钮,则图像和文本都会被选中。之后,我们在点击 粘贴 按钮,则控制台会输出从剪贴板中读取的实际内容。在分析具体的实现方式前,我们先来看一下对应的页面结构:

<div id="container">
   <img data-original="http://cdn.semlinker.com/abao.png" width="80" height="80" />
   <p>大家好,我是阿宝哥</p>
</div>
<button onclick="writeDataToClipboard()">复制</button>
<button onclick="readDataFromClipboard()">粘贴</button>

上面的页面结构很简单,下一步我们来逐步分析一下以上功能的实现过程。

5.1 请求剪贴板写权限

默认情况下,会为当前的激活的页面自动授予剪贴板的写入权限。出于安全方面考虑,这里我们还是主动向用户请求剪贴板的写入权限:

async function askWritePermission() {
  try {
    const { state } = await navigator.permissions.query({
      name: "clipboard-write",
    });
      return state === "granted";
  } catch (error) {
      return false;
  }
}

5.2 往剪贴板写入图像和普通文本数据

要往剪贴板写入图像数据,我们就需要使用 navigator.clipboard 对象提供的 write 方法。如果要写入图像数据,我们就需要获取该图像对应的 Blob 对象,这里我们可以通过 fetch API 从网络上获取图像对应的响应对象并把它转化成 Blob 对象,具体实现方式如下:

async function createImageBlob(url) {
  const response = await fetch(url);
  return await response.blob();
}

而对于普通文本来说,只需要使用前面介绍的 Blob API 就可以把普通文本转换为 Blob 对象:

function createTextBlob(text) {
  return new Blob([text], { type: "text/plain" });
}

在创建完图像和普通文本对应的 Blob 对象之后,我们就可以利用它们来创建 ClipboardItem 对象,然后再调用 write 方法把这些数据写入到剪贴板中,对应的代码如下所示:

async function writeDataToClipboard() {
  if (askWritePermission()) {
    if (navigator.clipboard && navigator.clipboard.write) {
        const textBlob = createTextBlob("大家好,我是阿宝哥");
        const imageBlob = await createImageBlob(
          "http://cdn.semlinker.com/abao.png"
        );
        try {
          const item = new ClipboardItem({
            [textBlob.type]: textBlob,
            [imageBlob.type]: imageBlob,
          });
          select(document.querySelector("#container"));
          await navigator.clipboard.write([item]);
          console.log("文本和图像复制成功");
        } catch (error) {
          console.error("文本和图像复制失败", error);
        }
      }
   }
}

在以上代码中,使用了一个 select 方法,该方法用于实现选择的效果,对应的代码如下所示:

function select(element) {
  const selection = window.getSelection();
  const range = document.createRange();
  range.selectNodeContents(element);
  selection.removeAllRanges();
  selection.addRange(range);
}

通过 writeDataToClipboard 方法,我们已经把图像和普通文本数据写入剪贴板了。下面我们来使用 navigator.clipboard 对象提供的 read 方法,来读取已写入的数据。如果你需要读取剪贴板的数据,则需要向用户请求 clipboard-read 权限。

5.3 请求剪贴板读取权限

这里我们定义了一个 askReadPermission 函数来向用户请求剪贴板读取权限:

async function askReadPermission() {
  try {
    const { state } = await navigator.permissions.query({
      name: "clipboard-read",
    });
    return state === "granted";
  } catch (error) {
    return false;
  }
}

当调用 askReadPermission 方法后,将会向当前用户请求剪贴板读取权限,对应的效果如下图所示:

5.4 读取剪贴板中已写入的数据

创建好 askReadPermission 函数,我们就可以利用之前介绍的 navigator.clipboard.read 方法来读取剪贴板的数据了:

async function readDataFromClipboard() {
  if (askReadPermission()) {
    if (navigator.clipboard && navigator.clipboard.read) {
      try {
        const clipboardItems = await navigator.clipboard.read();
        for (const clipboardItem of clipboardItems) {
          console.dir(clipboardItem);
          for (const type of clipboardItem.types) {
            const blob = await clipboardItem.getType(type);
            console.log("已读取剪贴板中的内容:", await blob.text());
          }
        }
      } catch (err) {
         console.error("读取剪贴板内容失败: ", err);
      }
     }
   }
}

其实,除了点击 粘贴 按钮之外,我们还可以通过监听 paste 事件来读取剪贴板中的数据。需要注意的是,如果当前的浏览器不支持异步 Clipboard API,我们可以通过 clipboardData.getData 方法来读取剪贴板中的文本数据:

document.addEventListener('paste', async (e) => {
  e.preventDefault();
  let text;
  if (navigator.clipboard) {
    text = await navigator.clipboard.readText();
  } else {
    text = e.clipboardData.getData('text/plain');
  }
  console.log('已获取的文本数据: ', text);
});

而对于图像数据,则可以通过以下方式进行读取:

const IMAGE_MIME_REGEX = /^image\/(p?jpeg|gif|png)$/i;

document.addEventListener("paste", async (e) => {
  e.preventDefault();
  if (navigator.clipboard) {
    let clipboardItems = await navigator.clipboard.read();
    for (const clipboardItem of clipboardItems) {
       for (const type of clipboardItem.types) {
         if (IMAGE_MIME_REGEX.test(type)) {
           const blob = await clipboardItem.getType(type);
           loadImage(blob);
           return;
         }
        }
     }
   } else {
       const items = e.clipboardData.items;
       for (let i = 0; i < items.length; i++) {
         if (IMAGE_MIME_REGEX.test(items[i].type)) {
         loadImage(items[i].getAsFile());
         return;
       }
    }
  }
});

以上代码中的 loadImage 方法用于实现把复制的图片插入到当前选区已选择的区域中,对应的代码如下:

function loadImage(file) {
  const reader = new FileReader();
  reader.onload = function (e) {
    let img = document.createElement("img");
    img.src = e.target.result;

    let range = window.getSelection().getRangeAt(0);
    range.deleteContents();
    range.insertNode(img);
  };
  reader.readAsDataURL(file);
}

在前面代码中,我们监听了 document 对象的 paste 事件。除了该事件之外,与剪贴板相关的常见事件还有 copycut 事件。篇幅有限,阿宝哥就不继续展开介绍了,感兴趣的小伙伴可以自行阅读相关资料。好的,至此本文就已经结束了,希望阅读完本文之后,大家对异步的 Clipboard API 会有些了解,有写得不清楚的地方,欢迎你随时跟阿宝哥交流哟。

关注「全栈修仙之路」阅读阿宝哥原创的 4 本免费电子书(累计下载近 2万)及 9 篇源码分析系列教程。

查看 ”复制图片“ 完整示例(Gist)

六、参考资源

查看原文

赞 6 收藏 4 评论 0

阿宝哥 赞了文章 · 2020-12-21

工作3年,看啥资料能月薪30K?


作者:小傅哥 | https://github.com/fuzhengwei/CodeGuide/wiki

沉淀、分享、成长,让自己和他人都能有所收获!😄

一、前言

月薪30K年薪是多少?

按照月薪30K,年终奖2~3个月来算,再算上季度的绩效奖金、加班费,可能也有一些大小周和节假日的三倍工资等。综合起来的税前年收入整体差不多在46W左右。当然如果你在年会中了个大奖也可以算进去,或者阳光普照个IPhone!

那30K月薪差不多是一个什么级别?不知道大家有没有看过下面这张图,这个图来自一个薪资统计的网站,如下:

互联网薪资对标 duibiao.info

  • 以上这种图的收入除了月薪还包括了,奖金、年终奖、股票,有些公司给的股票是比较多的。股票有一定的解禁期,并不是一次能拿完。
  • 那如果想拿月薪30K,基本是拿到了一个阿里的P6以及横向对标的级别。当然可能有些同学是在内部晋升加薪的,那样可能会略有差别。

30K对于工作3~5年还是蛮香的,但互联网大厂也确实不那么容易进去,如果在传统行业耽误了几年或者头几年做的项目单一,个人技术能力成长缓慢,过了30岁还真的挺难进去的。当然不是说30岁不要,只不过到了30岁,会要求面到更高的级别。

一般面试会从多方面进行考察,判断求职者是否满足招聘要求,如下图:但也有很牛皮的求职者可能就一两个问题的回答,就已经把面试官镇住了!

综上,梳理出七个方向的面试考点,包括:基本功底、常用技术、技术深度、技术经验、学习能力、工作能力、项目经验。

  • 基本功底,是一个程序员的主科目语言的学习程度的一个基本考察,这部分内容需要平时大量积累和总结。否则一本简单的Java书很难全部给你讲透彻,因为Java中包括了太多的内容,远不止API使用。
  • 常用技术,聊的是你的技术广度,和岗位技术匹配度。比如需要用到过RPC,那你用过Dubbo。如果你的公司暂时用的技术不多,或者还是处于单体服务,那么需要自己补充。
  • 技术深入,除了技术广度接下来就是技术深入,在你常用的技术栈中,你有多了解他们,了解源码吗、了解运行机制吗、了解设计原理吗。这部分内容常被人说是造火箭,但这部分内容非常重要,可以承上启下的贯穿个人修为和薪资待遇。
  • 技术经验,什么是技术经验呢?这是落地能力,除了你可能认为上面一些是纸上谈兵,是造火箭。那么接下来这部分内容就是你是否真造过一个火箭,真完成过一个难题。所以这部分是从结果证明,不是你会什么,而是你做过什么。
  • 学习能力,作为程序员你是否保持热情,是否依旧在积极努力的关注技术,是否为自己的成长不断添砖加瓦、是否还有好奇心和较强的求知欲。一般会从这里看你是不是一个真正的Coder!
  • 工作能力,以上的种种能力,最终要体现到工作上,要能看出你的交付能力。否则即使你再优秀,也不能把你当成一个吉祥物。工作能力的体现,才是真的为团队、为部门、为公司,贡献价值的。
  • 项目经验,这项内容会根据不同公司的不同业务线而不同,就像你懂交易、支付,那么面试花呗、借呗、白条等工作岗位就会很吃香。

好! 接下来小傅哥就带着你逐步介绍七个方向中的每一刻具体有哪些内容以及该如何学习。走起!

二、技术大纲

1. 基本功底

图 16-1 基本功底

  • 重要程度:⭐⭐⭐⭐
  • 内容介绍:数据结构讲的就是把数据放在不同形态的结构中,堆栈队列链表数组等。而算法逻辑就是把这些存放在数据结构中的数据按照一定规则进行增删改查,也就是二分、快排、动态规划、搜索等。而一门语言的核心技术就包括了对数据结构和算法的具体实现,像是我们用到的结合框架,ArrayList、HashMap等都是具体的实现。除此之外,在Java的核心技术中还要学习多线程、代理、反射等技术。这不只是面试内容,更是写好代码的基础!
  • 学习资料:算法图解、大话数据结构、数据结构与算法分析、算法导论、算法之美、计算机程序设计艺术
  • 语重心长:学习,从来不只仅仅是为了当下工作需要。简单的CRUD也可能真的不需要复杂的设计,但个人的年龄和能力一直要成正比!

2. 常用技术

图 16-2 常用技术

  • 重要程度:⭐⭐⭐⭐
  • 内容介绍:这部分内容是一个互联网研发中常用的技术栈内容,可能每个公司会有一些同类的其他技术,比如RPC框架就有很多种,但技术核心原理基本一致。可能以上的内容看上去比较杂,也可能有一些是你还没有接触过的,可以从上到下逐步了解。
  • 学习资料:http://tutorials.jenkov.comhttps://tech.meituan.com/http://mysql.taobao.org/monthly/、《面向模式的软件架构》、《设计原本》、《架构之美》、《Clean Architecture》
  • 语重心长:如果你并不想做一个工具人,就给自己的知识架构体系建设的完整一些,也算是风险抵抗了!

3. 技术深度

图 16-3 技术深度

  • 重要程度:⭐⭐⭐⭐⭐
  • 内容介绍:这一部分内容经常在面试求职过程中被称为造火箭、八股文。因为这部分知识探索到了JVM的运行机制,甚至去翻看C++源码,也包括JDK源码,同时还有框架的实现机制。除此之外,还有的公司会拓展到你可能完全没接触过的字节码插桩、全链路监控等等。
  • 学习资料:《java虚拟机规范》、《Java并发编程实战》、《多处理器编程的艺术》、《面经手册》《字节码编程》
  • 语重心长:有人说这叫内卷,那难道高考不卷?车牌号不卷?只要有资源竞争,就一定会有争夺。

4. 技术经验

图 16-4 技术经验

  • 重要程度:⭐⭐⭐⭐⭐
  • 内容介绍:如果你说问你源码、机制是造飞机,那技术的落地才是你真正的本事。这里一部分是框架、架构的搭建,另外一部分是源码和核心组件的使用。也就是你的核心框架源码学习,是否能做到技术迁移运用到你的项目中,做出可落地的程序。学习、沉淀、积累,这更像一盘大棋!
  • 学习资料:CodeGuide
  • 语重心长:不造轮子?对个人来说,轮子越多,车就越稳!

5. 学习能力

图 16-5 学习能力

  • 重要程度:⭐⭐⭐⭐
  • 内容介绍:学习能力主要是输入和输出,一遍吸纳知识,一遍沉淀知识。如果只看不记录不写,早早晚晚也就忘没了。这方便沉淀下来的内容都是个人的技术标签,尤其是参与过开源项目,或者自己有一个项目得到认可。
  • 学习资料:https://github.comhttps://stackoverflow.comhttps://www.csdn.nethttps://www.cnblogs.com
  • 语重心长:写博客真的是一种非常好的学习方式,每当你要输出一个知识的时候,你就需要阅读、收集、整理、汇总。日复一日的沉淀,终究会让你有非常大的提升。

6. 工作能力

图 16-6 工作能力

  • 重要程度:⭐⭐⭐⭐
  • 内容介绍:招聘人你觉得是先看能力还是先看素质?其实很多团队招聘是先看人的,如果你不能表现出一个积极、乐观、抗压、不玻璃心的态度,团队招聘是会有些抗拒的,谁也不希望招聘一个需要哄着的码宝男。但工作能力同样重要,最终是你的担事心态和担事能力来撑起你的工资和职位。
  • 学习资料:《非暴力沟通》、《关键对话-如何高效能沟通》、《逆商:我们该如何应对坏事件》、《人月神话》
  • 语重心长:沟通是解决双方或多方的认知偏差问题最终达成共识,情商是沟通的润滑剂,无论对谁都应该保持自己为追求更好而有的格局。

7. 项目经验

图 16-7 项目经验

  • 重要程度:⭐⭐⭐⭐
  • 内容介绍:项目经验来自于各个不同行业的技术范围,比如:社交、电商、外卖、出行、视频、音乐、汽车、支付、短视频等等,都会在各自的领域有一定的技术壁垒和相同之处。所以一般做游戏开发的可能跳槽到交易支付,还是会有很多不了解的。所以尽可能是在自己的行业内跳槽,或者你可以做到知识的拓展,自己多学习。
  • 语重心长:不要守着自己的一亩三分地,多看看、多了解。

三、30岁程序员占比

本周在群里做了一次简单的《2020年互联网程序员年龄分布统计》,因为人群的关系可能数据是有一些不准。但这份数据可以作为参考,也可以参与投票。

选项票数占比
未满 18 岁 - 19 岁113.9 %
20-25 岁10838.6 %
26-30 岁11139.6 %
31-35 岁279.6 %
36-40 岁113.9 %
41-45 岁93.2 %
46岁及以上31.1 %
  • 主力程序员集中在25~30岁,也就是刚毕业到工作7年左右。
  • 30以后的程序员呢?是不写代码了吗?其实,其实从这数据可以看出30以后的程序可能是晋升做管理,几乎不怎么参与到各种技术群的学习了。但也有另外一个现实,就是30岁以后基本都已经结婚生子,上有老、下有小。基本是没有自己的时间,也就没有了学习新知识的时间,也没有参与到各种技术群的时间。

统计数据

2020年互联网程序员年龄分布统计,截图

参与投票

2020年互联网程序员年龄分布统计,投票

四、总结

  • 与抵抗互联网风险相比能做的,只能是多学习、多沉淀、多积累。让30岁有30岁的能力,35岁有35岁的经历。因为没有所谓的安全,只有拥有留下的本事和走出去的能力才是安全的。
  • 30岁以后面临的不只是学习技术,还有很多原因是没有时间。有家庭、有父母、有妻子,有生活的杂事,有工作的占据,很难拿出一个时间给自己。哪怕是健身、学习,也得要挤时间。
  • 大部分程序员的愿望是什么?做过一次5年后的愿望收集,大部分希望升官发财、家庭美好、买车买房,也有希望一屋两人三餐四季,平平淡淡。其实大家在这个行业都很累,我的愿望可能是以后蜗居在天津,有个大书房、写写书、开车逛逛,有自由的时间。来自:程序员的愿望

五、系列推荐


我的博客:https://bugstack.cn

查看原文

赞 13 收藏 6 评论 0

认证与成就

  • 获得 4275 次点赞
  • 获得 20 枚徽章 获得 1 枚金徽章, 获得 10 枚银徽章, 获得 9 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • Angular FAQ

    Angular 常见问题汇总(2.x ~ 4.x)

  • Angular 2 & Ionic 2 资料汇总

    Angular 2 & Ionic 2 资料汇总

  • HTTP资源大全

    涉及 B/S、URI、MIME、HTTP请求和响应报文、HTTP 请求方法和状态码,并收录了 HTTP 经典教程和相关工具,如 Cookie 与 Session、HTTP 缓存、CORS、HTTP/2、HTTP爬虫、HTTPS及常用的HTTP抓包工具、Chrome相关插件、各平台HTTP包、压力测试工具等资料

注册于 2017-03-09
个人主页被 47.5k 人浏览