Kayo's Melody https://kayosite.com Practice Makes Perfect Fri, 18 Mar 2022 15:04:17 +0000 zh-CN hourly 1 https://cdn.kayosite.com/wp-content/uploads/2021/08/image.jpg?imageMogr2/thumbnail/!64x64r|imageMogr2/gravity/Center/crop/64x64 Kayo's Melody https://kayosite.com 32 32 115630942 Vue 响应式原理剖析 —— 数据更新常见问题 https://kayosite.com/vue-reactivity-common-problems.html https://kayosite.com/vue-reactivity-common-problems.html#comments Tue, 15 Mar 2022 06:38:20 +0000 https://kayosite.com/?p=3809 概况

在 Vue 开发的过程中,多少都会遇到数据更新后,页面没有更新渲染这类问题。而在上两篇文章《Vue 响应式原理剖析 —— 从实例化、渲染到数据更新(上)》《Vue 响应式原理剖析 —— 从实例化、渲染到数据更新(下)》中,从「实例化」、「渲染」、「数据更新」三条线完整地讲述了 Vue「响应式」的工作原理,本文正是基于这些原理去解决一些常见的数据更新相关问题。

对象数据的某些修改无法被检听?

如下的一个场景,obj.message 赋值时能否被监听响应呢?

var vm = new Vue({
    data: {
        obj: {
            a: 1
        },
    },
    template: '<div>{{ obj.message }}</div>'
});

vm.obj.message = 'modified';

答案是不能被监听的。原因:对象属性的添加和删除无法被 Object.defineProperty 监听,正如前文所述,Vue 的数据响应式基于 Object.defineProperty 实现,因此也受限。

解决办法: Vue 提供了特定的方法 vm.$set(obj, propertyName, newValue) 来处理这种情况,至于该方法的具体逻辑,后面会详细展开说明。

数组数据的某些修改无法被监听?

如下的一个场景,三个赋值语句里面,哪个能被监听响应呢?

const vm = new Vue({
    data: {
        items: [1, 2, 3, 4, 5],
    },
});
vm.items[1] = 8;
vm.items[5] = 6;
vm.items.length = 2;

答案是三个操作都不能被监听到。原因:

  • 第二个操作 vm.items[5] = 6 应该是比较明显的,与上面对象添加和删除属性类似,数组新添加的元素和删除元素无法被 Object.defineProperty 监听。
  • 第三个操作 vm.items.length = 2 也是由于 Object.defineProperty 的限制,数组的长度直接修改也无法被监听。
  • 最容易误判的可能是第一个操作 vm.items[1] = 8,一种较为常规的说法是 Object.defineProperty 不支持监听数组元素的变化,要验证这个说法可以直接用一个例子说明真实的情况。

如下的一个例子:

function defineReactive(obj, key, val) {
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get() {
            console.log('读取 index ' + key, '当前值是 ' + val)
            return val;
        },
        set(newVal) {
            if (val === newVal) {
                return;
            }
            val = newVal
            console.log('修改 index ' + key, '新值是 ' + val)
        }
    })
}

const testArray = [1, 2, 3, 4, 5]

testArray.forEach((c, index) => {
    defineReactive(testArray, index, c)
});

testArray[0] = 100;
testArray[5] = 600;
使用 Object.defineProperty 监听数组

也可以点击这里打开示例查看控制台输出。可以看到,超出范围的数组元素操作 Object.defineProperty 确实无法监听,但范围内的元素重新赋值是可以被监听的。那么为什么在 Vue 中对数组类型的 data,直接使用下标赋值无法被监听呢?

答案是出于性能考虑,从上面的基础例子中可以看到,对象和数组如果需要监听每个属性和元素,实际上是对每个属性或者元素进行 Object.defineProperty 劫持,对象是监听 key 而数组则是以数字下标作为 key,数组的数据量可能会很大,因此 Vue 出于性能考虑,并没有对元素下标进行响应式处理。作为补充,Vue 对数组原型链上的几个方法进行劫持,对于会导致元素新增的3个方法 pushpopunshift 会在内部获取新增的元素,执行响应式的处理:

const methodsToPatch = [
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]

methodsToPatch.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator (...args) {
    const result = original.apply(this, args)
    const ob = this.__ob__
    let inserted
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args
        break
      case 'splice':
        inserted = args.slice(2)
        break
    }
    if (inserted) ob.observeArray(inserted)
    ob.dep.notify()
    return result
  })
})

与对象类似,如果需要为数字元素重新赋值,可以使用 vm.$set(arr, indexOfItem, newValue) 方法,这里展示一下 $set 的具体实现:

// 精简了非 production 逻辑
export function set (target: Array<any> | Object, key: any, val: any): any {
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
  }
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  const ob = (target: any).__ob__
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
  }
  if (!ob) {
    target[key] = val
    return val
  }
  defineReactive(ob.value, key, val)
  ob.dep.notify()
  return val
}

主要的逻辑包括以下操作:

  1. 这个方法同时用于对象和数组,因此第一步会检验传入的 target 是否为数组,并且传入的 key(即数组的数字下标)是否符合数组的长度范围(如上面所述,Object.defineProperty 不支持劫持新添加的元素),符合的元素会调用 splice 插入到数组中,由于 splice 已经被劫持,新增加的元素会进行「响应式」处理。
  2. 判断如果 key 原先已存在,则无需再监听。
  3. 判断是根节点 Vue,即最外层的 Vue,或者已经有 __ob__ 属性(表示已经进行响应式处理,详情可以浏览前文),则无需再进行监听。
  4. 如果不符合前面的条件,则表明该属性需要执行「响应式」处理,会调用 defineReactive 方法(响应式数据封装的入口方法,详情可以浏览前文)。

]]>
https://kayosite.com/vue-reactivity-common-problems.html/feed 2 3809
Vue 响应式原理剖析 —— 从实例化、渲染到数据更新(下) https://kayosite.com/vue-reactivity-from-instance-render-to-update-part-two.html https://kayosite.com/vue-reactivity-from-instance-render-to-update-part-two.html#comments Tue, 15 Mar 2022 06:38:00 +0000 https://kayosite.com/?p=3806 上一篇文章中,梳理了 Vue 实例化和渲染的基本逻辑,并且介绍了订阅者模式这种设计模式,Vue 的「响应式」实现本质上也是一个订阅者模式,但是由于 Vue 需要考虑更加复杂的情况,并且需要在其中作出大量优化操作,因此具体实现也会复杂很多。通过上面对订阅者模式的介绍,观察目标类观察者管理类观察者是订阅者模式中的三个基本要素,Vue 内部也会有对应的实现,下面通过更详细地说明 Vue「响应式」的实现,同时发掘在 Vue 中订阅者三要素分别是什么。

Vue 响应式实现

正正如上文开头所述,本文会从「实例化」、「渲染」、「数据更新」三条线讲述「响应式」的工作过程,首先可以总结出三条线的作用:

  1. 实例化 Vue —— 负责定义好响应式的相关逻辑。
  2. 渲染 —— 负责执行响应式的逻辑
  3. 数据更新 —— 负责响应式逻辑的二次执行

上面梳理了的是三条线的主线逻辑,下面开始聚焦到「响应式」的部分。

实例化 Vue —— 负责定义好响应式的逻辑

if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    ob = value.__ob__
} else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
) {
    ob = new Observer(value)
}

回顾前面提到的 observer 方法,这是其中核心的部分,它的基本逻辑是这样的:

  1. 判断如果有 __ob__ 则直接使用。
  2. 没有 __ob__ 会走一系列的判断,然后把数据传入到 new Observer 创建响应式数据。

首先要分析的就是这一系列的判断,这些实际上都是对需要做响应式封装的数据进行检查的判断,shouldObserve 是默认为 true 的全局静态变量,isServerRenderingArray.isArray 顾名思义判断是否为服务端渲染和判断是否为数组,value._isVue 是判断是否为最根的 Vue 实例,根实例只是一个壳,是不需要处理响应式的,因此比较特别的是 isPlainObjectObject.isExtensible,这是两个含义不是很直观的判断。

isPlainObject

“Plain Object — 通过 {} 或者 new Object 创建的纯粹的对象”,这是对于 Plain Object 的定义。在 JavaScript 中,Function,Array 都继承于 Object,也拥有 Object 的特性,但为了避免产生额外的问题,框架在数据上通常都会使用 Plain Object。要区分 Plain Object 也很简单,很多框架里都有关于 Plain Object 的判断实践,而 Vue 则是使用原型判断,例如以下这段代码:

// Plain object
var plainObj1 = {};
var plainObj2 = { name : 'myName' };
var plainObj3 = new Object();
// Non Plain object
var Person = function(){};

console.log(plainObj1.__proto__); // {}
console.log(plainObj2.__proto__); // {}
console.log(plainObj3.__proto__); // {}
console.log(Person.__proto__); // [Function]

打印结果中,原型的值是不一样的,Vue 的 isPlainObject 具体实现如下:

var _toString = Object.prototype.toString;

function isPlainObject (obj) {
  return _toString.call(obj) === '[object Object]'
}

Object.isExtensible

“Object.isExtensible() 判断一个对象是否是可扩展的,即是否可以在它上面添加新的属性”,这是 Object.isExtensible() 的说明,看以下的例子:

// 新对象默认可扩展
var empty = {};
console.log(Object.isExtensible(empty)); // true

// 通过 Object.preventExtensions 使变得不可扩展
Object.preventExtensions(empty);
console.log(Object.isExtensible(empty)); // false

// 密封对象不可扩展
var sealed = Object.seal({});
console.log(Object.isExtensible(sealed)); // false

// 冻结对象也不可扩展
var frozen = Object.freeze({});
console.log(Object.isExtensible(frozen)); // false

// 尝试给不可扩展的对象添加属性
empty.a = 1;
console.log('modified empty: ', empty); // modified empty:  {}

一个直接创建的 Plain Object 默认是可扩展的,也可以通过一些原生方法把对象变为不可扩展,另外密封和冻结对象都是不可扩展的,不可扩展的元素添加属性不会报错,但是会添加无效。那为什么 Vue 要求响应式数据对象必须要可扩展呢?原因很简单,在上面介绍 observer 方法中,核心的步骤就是要给数据对象添加 __ob__ 属性,用于缓存响应式数据的封装结果。

定义响应式数据

回到实例化 Vue 的流程,在判断传入的数据对象如果没有 __ob__ 属性后,会调用 new Observer,这是响应式处理的真正入口类。

constructor (value: any) {
  this.value = value
  this.dep = new Dep()
  this.vmCount = 0
  def(value, '__ob__', this)
  if (Array.isArray(value)) {
    if (hasProto) {
      protoAugment(value, arrayMethods)
    } else {
      copyAugment(value, arrayMethods, arrayKeys)
    }
    this.observeArray(value)
  } else {
    this.walk(value)
  }
}

首先是把当前的 Observer 实例赋值给当前对象的 __ob__ 属性,然后判断如果是数组则遍历每个 item 调用 observer,由于之前调用 observer 时就进行了判断,传入的数据类型只能是数组或者对象,因此这里 else 就按对象处理,调用 walk 方法。

walk (obj: Object) {
  const keys = Object.keys(obj)
  for (let i = 0; i < keys.length; i++) {
    defineReactive(obj, keys[i])
  }
}

walk 主要的作用是为数据对象的每个 key 调用 defineReactive 方法,defineReactive 的主要逻辑是为传入数据的某个 key,基于 Object.defineProperty 劫持 getset 操作,这样数据读取和赋值时就会调用响应式的逻辑。由于基于 Object.defineProperty 实现了这个核心逻辑,因此 Vue 不支持 IE8 下运行。

get: function reactiveGetter () {
  const value = getter ? getter.call(obj) : val
  if (Dep.target) {
    dep.depend()
    if (childOb) {
      childOb.dep.depend()
      if (Array.isArray(value)) {
        dependArray(value)
      }
    }
  }
  return value
}

首先看看 get 操作的劫持,首先是通过原生的 getter 获取数据的值,然后判断 Dep.target 是否存在,这里可能会有疑问,没有看到它的赋值时机,所以 Dep.target 究竟是什么呢?实际上现在不用关注它的赋值,因为正如前面强调的,当前这些实例化的操作,只是把「响应式」的数据先定义好,也就是还不用运行,到了渲染过程的时候,才会对 Dep.target 进行赋值。

// 精简了部分逻辑
export default class Dep {
  static target: ?Watcher;
  id: number;
  subs: Array<Watcher>;

  constructor () {
    this.id = uid++
    this.subs = []
  }

  addSub (sub: Watcher) {
    this.subs.push(sub)
  }

  depend () {
    if (Dep.target) {
      Dep.target.addDep(this)
    }
  }

  notify () {
    const subs = this.subs.slice()
    if (process.env.NODE_ENV !== 'production' && !config.async) {
      subs.sort((a, b) => a.id - b.id)
    }
    for (let i = 0, l = subs.length; i < l; i++) {
      subs[i].update()
    }
  }
}

假如 Dep.target 已经赋值了,接下来会执行以下操作:

  1. 调用 dep.depend() 进行依赖收集,在 Dep 类源码中可以发现,这个方法实际上是把当前 target,即当前渲染的 Watcher 加入到 dep 实例的一个数组中,保存下来。
  2. 如果数据中有子值也是对象,则对子值进行依赖收集。

也就是 get 调用后数据的 dep 会持有关联的 Watcher

// 精简非正式环境逻辑
set: function reactiveSetter (newVal) {
  const value = getter ? getter.call(obj) : val
  if (newVal === value || (newVal !== newVal && value !== value)) {
    return
  }
  // #7981: for accessor properties without setter
  if (getter && !setter) return
  if (setter) {
    setter.call(obj, newVal)
  } else {
    val = newVal
  }
  childOb = !shallow && observe(newVal)
  dep.notify()
}

然后看看 set 的操作:

  1. 获取当前最新的数据值。
  2. 判断新值如果等于旧值则直接跳过下面的操作。newVal !== newVal && value !== value 是为了避免一些特殊情况,例如 newValNaN,由于 NaN === NaNfalse,所以需要这样一个特殊的判断。
  3. 然后跳过没有原生 setter 但有原生 getter 的情况。
  4. 接着调用 setter 赋新值。
  5. 最后是调用 dep.notify(),根据上面 Dep 类的源码可以知道,这实际上就是遍历之前收集的 Watcher,然后逐个调用它们的 update 方法,Watcher 会去执行更新逻辑。

到这里,实例化中「响应式」相关逻辑已经完整分析清楚了,订阅者模式的相关要素也很清晰:

  • Dep观察目标Watcher观察者,每个数据对应一个 Dep 实例 depget 数据时会触发 dep 收集了数据相关的 Watcher,相当于观察目标收集了观察者。
  • Watcher 也记录了相关的 dep,方便后续更新时做优化。这是与普通订阅者模式最大的区别,后续会展开说明。
  • set 数据时会触发 dep 通知相关的 Watcher 更新,而具体的更新逻辑,等第三个小章节“数据更新”再详细说明。

如前面所说的,实例化中的响应式处理实际上是负责定义响应式的逻辑。接下来看看渲染的逻辑。

渲染 —— 负责执行响应式的逻辑

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}
new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted && !vm._isDestroyed) {
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)

回顾调用 $mount,挂载实例的这块代码,重新聚焦几个点:

  • Watcher 绑定的是 Vue 的实例 vm,传入的第二个参数是 vm 的更新方法,里面会先调用 vm_render() 方法。
  • Watcher 的作用包括在需要时触发 _render(),即重新计算 vnode,然后 _update 调用 _patch,即重新渲染 DOM,从而实现整个 Vue 实例的更新。

因此对于这个流程,响应式相关的逻辑重点在 new Watcher,接下来看看 Watcherconstructor

// 精简了非 production 的逻辑
constructor (vm: Component, expOrFn: string | Function, cb: Function, options?: ?Object, isRenderWatcher?: boolean) {
  this.vm = vm
  if (isRenderWatcher) { vm._watcher = this }
  vm._watchers.push(this)
  if (options) {
    this.deep = !!options.deep
    this.user = !!options.user
    this.lazy = !!options.lazy
    this.sync = !!options.sync
    this.before = options.before
  } else {
    this.deep = this.user = this.lazy = this.sync = false
  }
  this.cb = cb
  this.id = ++uid // uid for batching
  this.active = true
  this.dirty = this.lazy // for lazy watchers
  this.deps = []
  this.newDeps = []
  this.depIds = new Set()
  this.newDepIds = new Set()
  this.expression = process.env.NODE_ENV !== 'production' ? expOrFn.toString() : ''
  if (typeof expOrFn === 'function') {
    this.getter = expOrFn
  } else {
    this.getter = parsePath(expOrFn)
    if (!this.getter) { this.getter = noop }
  }
  this.value = this.lazy ? undefined : this.get()
}

constructor 的逻辑里,大部分都是定义变量,需要重点关注的主要是:

  1. 真正要处理的逻辑在 get() 方法里。
  2. Watcher 实例的 getter 就是传入的 updateComponent 方法,getter 会被保存到 Watcher 实例变量上。

接下来分析一下 get() 方法的逻辑:

get () {
  pushTarget(this)
  let value
  const vm = this.vm
  try {
    value = this.getter.call(vm, vm)
  } catch (e) {
    if (this.user) { handleError(e, vm, `getter for watcher "${this.expression}"`) }
    else { throw e}
  } finally {
    if (this.deep) { traverse(value) }
    popTarget()
    this.cleanupDeps()
  }
  return value
}

get() 方法主要是做两件事,调用 getter 以及进行「收集依赖」,getter 本质上就是 updateComponent,即上面介绍过的渲染更新组件的逻辑,这里不再详述这点,重点关注「收集依赖」的过程。

Dep.target = null
const targetStack = []

export function pushTarget (target: ?Watcher) {
  targetStack.push(target)
  Dep.target = target
}

上面是 pushTarget 的逻辑,它是 Dep 类下面的一个静态方法,本质上就是把当前的 Watcher 加入到一个栈中,并且赋值给 Dep.target,这里可以回应上面在劫持响应式数据 get 逻辑的一个疑问Dep.target 是在渲染过程中「收集依赖」时赋值的,因此真正执行响应式逻辑实际上是在渲染时才进行的。结合两个特性:

  • Vue 实例渲染是递归的,从子到父逐个完成,同时只有一个 Watcher 被渲染。
  • JS 是单线程的,Dep.target 在同一时刻只会被赋值成一个 Watcher

Vue 就是利用这两个特性,逐个执行 Watcher 的渲染逻辑,最终完成整个 Vue 应用的渲染,最后重点看看 this.cleanupDeps() 的逻辑。

为什么需要 cleanupDeps?

cleanupDeps () {
  let i = this.deps.length
  while (i--) {
    const dep = this.deps[i]
    if (!this.newDepIds.has(dep.id)) { dep.removeSub(this) }
  }
  let tmp = this.depIds
  this.depIds = this.newDepIds
  this.newDepIds = tmp
  this.newDepIds.clear()
  tmp = this.deps
  this.deps = this.newDeps
  this.newDeps = tmp
  this.newDeps.length = 0
}

this.cleanupDeps() 的逻辑主要是分成两块:

  1. newDepIds 里面不存在的 dep 实例找出来,然后把当前的 Watcher 从这个 dep 实例中移除,也就是后续 dep 对应的数据更新不用再通知当前 Watcher
  2. 清空当前的 newDepIds,把 deps 赋值成 newDeps

这样看无法直观看出来为什么需要实现一个这样的逻辑,举一个具体的例子:

<template>
  <div id="app">
    <div>
      a:{{ a }}
      <button @click="chnageA">点击修改 a 的值</button>
      <HelloWorld1 v-if="a % 2 === 0" :data="a" />
      <HelloWorld2 v-else :data="b" />
    </div>
  </div>
</template>

在这个例子中,ab 两个 dataa 在模板中直接用到,而 b 仅在 HellowWorld2 中作为 props 传递,当 a 为奇数时 ab 改变都会触发 App 更新渲染。

可以试想一下这样一个过程:

  1. 初始化时, ab 都为 1,在初始化的时候经常 observe 的处理,形成了两个 Dep 实例,dep(id=1,绑定 a)和 dep(id=2,绑定 b
  2. 渲染时 new Watcher 绑定了 App 这个 Vue 实例,然后 Dep.target 赋值成当前 Watcher,经常 Watchergetter -> updateComponent -> render() 这样一个过程,触发了 abget,从而进行依赖收集,把当前 Watcher 同时放入两个 dep 中。
  3. 然后把 a 改为2,触发了 aset 从而通知 Watcher 更新,重新触发 updateComponent 走到 render(),这个时候假如没有 cleanupDeps(),则这次 render() 触发依赖收集完成后,只是更新了 a 的值为2,而后续如果 b 修改值时,仍会通知 Watcher 更新,造成一次浪费的订阅更新。对于 Vue 这样的基础框架来说,如果每次依赖收集都重新进行,抛弃内存缓存记录,又会导致性能很差,无法适配各种常见,因此最终 Vue 的做法就是通过 WatcherDep 同时互相记录,来实现渲染优化,即订阅者也可以通知订阅目标抛弃掉一些无用的通知对象,减少浪费。

为了更好地说明这个过程,这里特意做了一张流程图完整表述整个过程:

cleanupDeps 的作用

数据更新 —— 负责响应式逻辑的二次执行

相对来说,数据 set 后的更新逻辑比较好理解,上面大概提到了,但其中的内部逻辑却是三条主线里最复杂的。上面稍微提到过,当数据 set 后,会触发 dep.notify(),即遍历之前收集的 Watcher,然后逐个调用它们的 update 方法,因此首先来看看 Watcherupdate 方法:

update () {
  /* istanbul ignore else */
  if (this.lazy) {
    this.dirty = true
  } else if (this.sync) {
    this.run()
  } else {
    queueWatcher(this)
  }
}

上面 Watcherconstructor 的代码里展示过,lazysync 这两个变量默认都是 false,因此可以先不用理会,也就是说 update 的主逻辑是把当前的 Watcher 作为参数调用 queueWatcher,顾名思义是把 Watcher 放入到一个队列中,接下来看看 queueWatcher 的具体处理。

// 精简了非 production 的逻辑
let waiting = false
let flushing = false

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    // queue the flush
    if (!waiting) {
      waiting = true
      nextTick(flushSchedulerQueue)
    }
  }
}

根据这里的逻辑,默认的情况都是直接把传入的 Watcher 加入到一个队列中,然后使用 nextTick 调用 flushSchedulerQueuenextTick 大家都比较熟悉,作用是把方法按周期调用,因此组件的实际渲染更新都不是即时的,而是每隔一个周期中集中处理,接下来看看 flushSchedulerQueue 的逻辑。

// 已精简非 production 逻辑
function flushSchedulerQueue () {
  currentFlushTimestamp = getNow()
  flushing = true
  let watcher, id
  queue.sort((a, b) => a.id - b.id)
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    watcher.run()
  }

  const activatedQueue = activatedChildren.slice()
  const updatedQueue = queue.slice()

  resetSchedulerState()

  // call component updated and activated hooks
  callActivatedHooks(activatedQueue)
  callUpdatedHooks(updatedQueue)
}

这里主要处理了三件事情:

  1. 按 id 从小到大排序 WatcherWatchweid 是创建时自增的,渲染是从外层递归的,也就是父元素会排在队列的前面。为什么要这样排呢?实际上也是为了性能优化,把父元素放在队列的前面,就会优先处理父元素,因此如果父元素销毁了,就可以直接跳过后面子元素的渲染更新。
  2. 遍历队列调用每个 Watcherrun() 方法。
  3. queue.length 是动态的,Vue 没有把队列长度缓存起来,是因为 queue 在调用过程中可能会增删 Watcher,例如上面的例子中,a 的改变可以导致 HelloWorld1Watcher 加入到队列中,而 HellowWorld2Watch 则不再需要被渲染,因此 queue 的长度无法缓存。
run () {
  if (this.active) {
    const value = this.get()
    if (
      value !== this.value ||
      isObject(value) ||
      this.deep
    ) {
      const oldValue = this.value
      this.value = value
      if (this.user) {
        const info = `callback for watcher "${this.expression}"`
        invokeWithErrorHandling(this.cb, this.vm, [value, oldValue], this.vm, info)
      } else {
        this.cb.call(this.vm, value, oldValue)
      }
    }
  }
}

最后看看 run() 方法,首先会调用 get() 方法,也就是会重新调用 updateComponent 和进行依赖收集,这里再关注一下 get() 后半部分的逻辑,之前文章内提到的 Watcher 其实都是绑定 Vue 实例的渲染 Watcher,Vue 中还有用户 Watcher,也就是平常监听 data 或者 props 值变化用的 Watcher,对于这些 Watcher,会有有效的返回值 value,因此 run() 里面还会对比 value 是否有变化,如果有就重新赋值,并且会执行回调。

至此,Vue「响应式」的整个逻辑以及在各个环节中分别所做的处理已经讲述完成,作为 Vue 的核心部分,「响应式」的整个逻辑较为庞大,也涉及实例化、渲染、数据更新三个环节,同时内部还有很多的性能考虑,因此单纯去看「响应式」的核心代码也不大好理解,后面还会有一篇短文来解答一些数据更新的常见问题。最后制作了一张完整的 Vue「响应式」逻辑流程图供参考。

Vue 如何实现响应式

]]>
https://kayosite.com/vue-reactivity-from-instance-render-to-update-part-two.html/feed 2 3806
Vue 响应式原理剖析 —— 从实例化、渲染到数据更新(上) https://kayosite.com/vue-reactivity-from-instance-render-to-update-part-one.html https://kayosite.com/vue-reactivity-from-instance-render-to-update-part-one.html#comments Tue, 15 Mar 2022 06:37:26 +0000 https://kayosite.com/?p=3803 概况

最近对一个基于 Vue 项目的 Sentry Issue 进行治理时,发现了大量 Issue 都是 Vue 内部逻辑引起的,为了更好地去解决问题,因此也复习了一遍 Vue2 的原理。

相比起 Vue3 更清晰的项目结构和实现,Vue2 中各个部分的实现存在较多的耦合,也导致其逻辑梳理起来较为复杂。其中「响应式」的部分是最为复杂也是最重要的一环,实际项目中大部分的 Issue 也与其相关,如 Vue2 官网中所述的那样:

“Vue 最独特的特性之一,是其非侵入性的响应式系统。数据模型仅仅是普通的 JavaScript 对象。而当你修改它们时,视图会进行更新。这使得状态管理非常简单直接,不过理解其工作原理同样重要,这样你可以避开一些常见的问题”。

在系统地梳理「响应式」工作原理的过程中,也参考了不少现有的文章,大部分都是围绕“依赖收集”、“派发更新”或者“Watcher”,“Dep”这些响应式相关的概念逻辑展开讲述,当然这些概念和逻辑是必不可少的要展开讲述的内容,但是如果单纯围绕这些内容展开来编写一篇文章,对于理解「响应式」在整个 Vue 中的工作过程可能会感到困惑。因此,本文会换一个角度,从 Vue 使用的过程展开说明「响应式」的工作原理,即从「实例化」、「渲染」、「数据更新」三条线讲述「响应式」的工作过程,分别对应的是如何定义响应式数据、如何触发响应式逻辑执行,以及如何触发响应式数据更新。

在介绍了「响应式」的工作原理之后,也会基于工作原理解决一些常见的数据更新相关的问题。

从实例化到渲染

import Vue from 'vue'
import App from './App.vue'

Vue.config.productionTip = false

new Vue({
  render: h => h(App),
}).$mount('#app')

以上是一段大家应该都很熟悉的代码,即 Vue Cli 创建的示例项目实例化 Vue 的代码,虽然是实例化代码,但实际上这里做了两件事:

  1. new Vue,即创建了一个 Vue 实例。
  2. 调用实例的 $mount 方法,即挂载 Vue 的渲染结果到 #app 这个节点上。

这里是 Vue 中两条重要的工作线,接下来看看在 Vue 内部这两个操作具体做了什么,当然会着重于「响应式」相关部分。

实例化过程

// 精简了非 production 的逻辑
function Vue (options) {
  this._init(options)
}

initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

export default Vue

首先是定义了 Vue 的构造函数,构造函数内会调用 _init 方法,定义构造函数后会调用 initMixinstateMixin 等方法,其中 initMixin 内会定义构造函数内的 _init 方法,因此先关注一下 initMixin

// 精简了非 production 逻辑
export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    vm._uid = uid++
    vm._isVue = true
    vm._self = vm
    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')

    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}

initMixin 方法内部会给 Vue 的原型扩展大量方法,其中初始的就是 _init 方法,包括生命周期、渲染函数(把模板构造成 render 函数,render 函数负责输出虚拟节点)、data/props、调用 created hook 等,对数据进行响应式封装的逻辑也是从这里开始的。

export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

initState 是负责处理 data 的核心,propsmethodscomputedwatch 这些常用的 Vue 的 options,也是在这里进行处理,主要的处理内容包括做一些检查,例如有名字冲突,比如比较常见的 warning:"Method xxx has already been defined as a prop.",就是在这个阶段做的检查,另外最重要的就是对数据进行响应式封装,接下来会以最常用也是最直观的 data 作为例子。

// 精简了非 production 逻辑
function initData (vm: Component) {
  let data = vm.$options.data
  data = vm._data = typeof data === 'function'
    ? getData(data, vm)
    : data || {}
  const keys = Object.keys(data)
  const props = vm.$options.props
  let i = keys.length
  while (i--) {
    const key = keys[i]
    if (props && hasOwn(props, key)) {
    } else if (!isReserved(key)) {
      proxy(vm, `_data`, key)
    }
  }
  observe(data, true /* asRootData */)
}

上图是 initData 的主体逻辑,主要的作用是对 data 的内容进行格式检查,比如必须是一个 isPlainObject(至于这是什么后面会详细说明),另外就是如上面提到的,进行名字校检防止冲突,例如如果有 datakeyprops 冲突了,就会报那个大家应该都很熟悉的 warning:"The data property xxx is already declared as a prop. Use prop default value instead.",最后就是真正的响应式逻辑 observe 方法。

到这里,实例化的主线已经梳理出来了,可以看到 new Vue 之后 Vue 的处理步骤,以及 data 这类 options 是如何走到数据响应式处理的。

调用 $mount,挂载实例

在 Vue 的示例中,实例化之后会调用 $mount 把渲染出来的 DOM 挂载到页面上,$mount 实际上是触发渲染的入口。

// 精简了非 production 逻辑
export function mountComponent (vm: Component, el: ?Element, hydrating?: boolean): Component {
  vm.$el = el
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode
  }
  callHook(vm, 'beforeMount')

  let updateComponent
  updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

$mount 首先会调用 mountComponent 方法,这是渲染的核心主线逻辑,按顺序分别做了以下的事情:

  1. 判断是否有传入 render 方法,render 方法是把 Vue 模板转换成 VNode 的方法,在 Vue 内部,如果 new Vue 时有 render 会优先使用,上面 new Vue 的示例就传入了 render 方法,也是大家比较熟悉的把 App.vue 传入的逻辑。如果没有传入 render 则会把 render 赋值成创建一个空 VNode 节点的方法。
  2. 调用 beforeMount 的钩子。
  3. 定义好 updateComponent 方法,该方法负责执行实例的渲染和更新,内部会调用 Vue 实例的 _update,而 _update 则传入了 render 的调用结果,即计算好的 VNode。_update 方法的内最重要的就是调用了 patch,即把 VNode 转换成真实 DOM 的方法,转换过程跟「响应式」关联不大,因此这里不针对 patch 展开太多。
  4. 创建一个 Watcher 实例,传入当前 Vue 的实例 vmupdateComponent,还有一些 options,例如 before 参数。
  5. 调用 mounted 钩子。

在梳理了 $mount 的过程后,可以梳理出一个清晰的 Vue 实例渲染主线,调用 new Vue 实例化 Vue,然后把 dataprops 等 options 进行校检和「响应式」封装,接着调用 $mount 开始进行渲染,首先创建一个 Watcher 对象跟 Vue 实例关联起来,并通过传入 updateComponent 方法维护实例的渲染和更新,render 作为 updateComponent,负责把模板转换成虚拟节点 VNode,后面的 patch 方法则把 VNode 转换成真实 DOM,最后挂载到页面上。而在这个过程中,实例化时定义好响应式数据,渲染时调用响应式数据的更新逻辑,最终实现整个更新逻辑。

订阅者模式

在上面的整个更新逻辑中,核心的「响应式」逻辑,应用了订阅者模式这种设计模式,在说明 Vue 具体是如何基于订阅者模式实现「响应式」之前,先来介绍一下订阅者模式。

什么是订阅者模式?

“一个目标对象管理所有相依于它的观察者对象,并且在它本身的状态改变时主动发出通知”。这是对订阅者的简单描述,在 JavaScript 中,订阅者模式是最常用的模式之一,例如经常用到的 DOM 事件监听也是一种订阅者模式,比如:

document.body.addEventListener('click', () => {
    console.log('clicked1');
});
document.body.addEventListener('click', () => {
    console.log('clicked2');
});

body 作为观察目标,订阅了 click 事件,当 body 被点击时就会向订阅者发出通知,订阅者依次输出 clicked1 和 clicked2,完成了一个订阅 - 通知 - 响应的过程。

订阅者模式的基础实现

根据上面的例子可以总结出订阅者模式的基础特征:

  1. 一个观察目标对象通常会有观察者管理类,包括了添加、删除、通知观察者更新三个主要操作。
  2. 一个或多个观察者,接收观察目标的通知并作出处理。

也就是说,观察目标类观察者管理类观察者是订阅者模式中的三个基本要素。基于以上特征,这里实现了一个简单的订阅者模式示例,其中观察者集合类 ObserverList 作为一个工具类用于管理观察者,观察者目标类 Subject 调用 ObserverList 进行实际的观察者(Observer)管理,以及在需要时发送更新通知给观察者,示例中的更新通知是更新随机数,观察者接受通知把最新的随机数输出。可以尝试打开控制台,点击示例中的按钮留意输出。

到这里,Vue 实例化和渲染的基本逻辑已经梳理出来,下一篇文章会详细说明 Vue「响应式」的具体实现。

]]>
https://kayosite.com/vue-reactivity-from-instance-render-to-update-part-one.html/feed 1 3803
从 Sass Breaking Change: Slash as Division 说起 https://kayosite.com/analysis-of-sass-breaking-change-slash-as-division.html https://kayosite.com/analysis-of-sass-breaking-change-slash-as-division.html#respond Sun, 26 Sep 2021 06:32:50 +0000 https://kayosite.com/?p=3753 最近在修改一个项目的时候,发现了一系列的 Sass 的告警——由除号引起的告警:

Sass Division 告警

目录

什么告警?

告警的内容很简单,用 / 作为除法已经在 Dart Sass 2.0.0 中被弃用了,作为一个 Sass 的基础语法,这次弃用属于 breaking change 了,因此目前编译时只是会抛出 warning 而不是 error,否则大量项目都无法正常运行。

研究了一下可以看到,Sass 官方特定用了一整个篇幅的文章,来阐述为何要作出这个修改,主要的原因在于,/ 在 Sass 中同时承担除号以及 CSS 分隔符的作用,例如:

Sass 代码

// 作为除号使用
.test_division {
    border-radius: ceil(28px / 2);
}

// 作为 CSS 分隔符使用
.test_operator {
    font: 12px/1.5 -apple-system, "SF UI Text", "PingFang SC", "Lucida Grande", "Microsoft YaHei", sans-serif;
}

实际编译出的 CSS 代码

.test_division {
    border-radius: 14px;
}

.test_operator {
    font: 8px -apple-system, "SF UI Text", "PingFang SC", "Lucida Grande", "Microsoft YaHei", sans-serif;
}

示例中有两个 /,一个是用作除号,另一个是作为 font 属性中 font-sizeline-height 的分隔符,可以看到作为除号使用的时候,/ 一般不会出现什么问题,但是作为分隔符使用的时候,/ 很容易被重载为除号,实际上要实现分隔符的效果,通常需要这样编写:

.test_operator {
    font: #{12px/1.5} -apple-system, "SF UI Text", "PingFang SC", "Lucida Grande", "Microsoft YaHei", sans-serif;
}

使用了插值(Interpolation)语法,包裹了 12px/1.5,插值的作用是仅解析 Sassscript,把 Sass 变量输出为实际的值,但不会进行运算,如果属性值比较复杂,则会导致编写的时候不大直观。

Sass 本身使用了 complex heuristics 的技术去判断 / 应该作为除号还是分隔符,complex heuristics 是需要回顾当前上下文的内容,来作出判定的,因此对于 Sass 来说存在一定消耗。

综合来说,原有的 / 语法对于开发者会带来一些困扰,尤其是随着 CSS 有更多的属性使用到了分隔符(例如 gridhsl() 等语法),同时对于 Sass 的维护以及编译也会带来一些额外的消耗,所以 Sass 最终决定重新定义除法,新的语法也相当清晰:

@use "sass:math";

.test_division {
    border-radius: math.div(28px, 2);
}

新的语法基于 Sass 的 module 语法,引入了相关的运算模块后,就可以调用 math.div 代替原来的 /,而 / 则只作为分隔符使用。

如何解决告警?

要解决告警,首先要知道为何项目中会出现这个告警,用到了这个语法的项目比较多,但目前只有这个项目出现了告警。

首先这个 breaking change 仅在 Dart Sass 的最新版本中才引入,Node Sass 的版本目前还没有跟上。另外该项目中是使用 "sass": "^1.30.0" 来声明 sass 模块的版本(即 Dart Sass 的 npm 包),重新执行 npm i 会导致安装上新版的 Dart Sass 从而出现告警,因此解决方案也围绕 Dart Sass 版本去处理。

降级 sass 模块

删除 package-lock.jsonnode_modules,把 package.json 中 sass 模块的版本改为 "sass": "~1.32.12",即版本号会少于 1.33.0,这个版本的 Dart Sass 并未引入 slash as division 的 breaking change。

更新业务语法

即按上面提到的方式,把相关的告警内容改为用新的 math.div 语法代替,如果涉及的业务量比较大,更新起来会比较费时。

使用 sass-migrator

sass-migrator 是 Sass 官方推出的迁移工具,方便开发者对原有的业务代码进行最新版本的 Sass 适配。sass-migrator 并不是把相关的旧语法直接替换为最新的语法,而且采用更稳固的方式,把代码安全地更新为符合最新要求的语法,例如上面的例子:

Sass 代码

.test_division {
    border-radius: ceil(28px / 2);
}

sass-migrator 的安装与调用

npm i -g sass-migrator
sass-migrator division test.scss

处理后的 Sass 代码

.test_division {
    border-radius: ceil(28px * 0.5);
}

可以看到,sass-migrator 并没有把原有的 / 修改为最新的 math.div 语法,而是修改为用乘法代替。实际上跟 sass 基于 complex heuristics 进行分析会把分隔符重载为除号一样,迁移工具也无法准确地判断每个 / 的作用,因此 sass-migrator 采用了稳固的方式去适配最新的 Sass 规则。

Dart Sass Vs Node Sass

Node Sass 由于没有引入最新的 Sass 特性,因此并不会出现这个告警,但并不建议使用 Node Sass 代替已经用 Dart Sass 编写的代码,主要是:

  • Dart Sass 已经是官方的首选,无论是新特性还是问题修复,Dart Sass 会有更强的时效性,担心新特性会为业务带来问题可以通过锁包进行控制。
  • Node Sass 实际上已经被官方定义为 deprecated,目前项目会维护一个主要版本,但是维护进度并不确定,并且明确没有计划再为 Node Sass 添加新特性,也不会适配 CSS 的新特性。
  • Node Sass 基于 LibSass 开发,而 LibSass 依赖了的模块安装比较麻烦,尤其是对于 Windows 的用户,它要求用户在 Windows 中必须安装 Python2 和 Visual Studio。

因此出于长期维护的考虑,选用 Dart Sass 也是一个趋势。

Dart Sass on Dart-VM 与 Dart Sass on NPM

目前 Dart Sass 有两种实现,分别是:

  • 基于 Dart-VM 的 Dart Sass
  • 基于纯 JavaScript 的 Dart Sass

根据官方的介绍,单独运行的命令行版本,是基于 Dart-VM 运行的,得益于 Dart-VM 的高性能,这个版本的 Dart Sass 性能非常好,适合用于编写脚本单独编译 Sass 文件。

而 NPM 中的 Dart Sass 则是纯 JavaScript 实现,因此可以很方便地用于前端项目构建。虽然 JavaScript 版本的 Dart Sass 性能比 LibSass 要差一些,但是对于样式代码的编译量来说,区别并不大。

三个版本的 Sass 编译速度对比

上图是 Dart Sass on Dart-VM、Dart Sass on NPM、Node Sass 分别去编译 BootStrap 4 的耗时(来源于 Stack Overflow),可以看出 Dart Sass on NPM(即 Dart Sass JS)比 Node Sass 还要慢很多(大概3倍的耗时),但实际上即使是 Bootstrap 这个体量,也只是2秒的耗时,考虑到官方和社区都逐步迁移到 Dart Sass 了,因此新项目也建议使用 Dart Sass。

]]>
https://kayosite.com/analysis-of-sass-breaking-change-slash-as-division.html/feed 0 3753
Matomo 从了解到落地——页面流量统计与分析最佳实践 https://kayosite.com/matomo-best-practices.html https://kayosite.com/matomo-best-practices.html#comments Wed, 07 Apr 2021 08:29:00 +0000 https://kayosite.com/?p=3585 背景

在开发面向内部使用的「内容管理平台」的过程中,我们不时会收到一些页面问题的反馈,但在本地调试的过程中,有大量无法在本地重现的问题,这些问题的出现跟用户的访问设备、网络环境、访问路径可能存在关联。为了方便快捷地去定位这些问题,我们试图为所有页面点击操作都加上打点记录,但在实际操作中,由于业务变更频繁,开发框架的限制,展示打点数据较为复杂等因素,通过打点排查问题的实际效果并不理想,因此我们希望引入完整的流量统计和用户行为分析来定位问题。

不同的方案分析对比

对于流量统计和用户行为分析记录的工具,行业内已经有大量成熟的解决方案,相对于自行打点,这些专门的流量通过平台和工具对于业务的基本没有侵入性,也解决了如何展示数据的问题。这些平台和工具中,有著名的 Google Analytics、百度统计、WebTrends 等,也有相对冷门的今天的主角 —— Matomo,而这些方案之间各有优劣:

解决方案/平台优势劣势
Google Analytics部署简单,只需在页面加入 JS 追踪器代码,数据分析快(小时级别),功能强大,分析维度丰富数据量大的时候偶尔会丢失数据,无法定制化
Adobe Analytics数据展示清晰明了,功能强大部署复杂,只有付费版本,技术支持和文档都较少
WebTrends数据分析维度丰富,报告全面,监控过程安全主要针对大客户,费用非常高
CNZZ部署和接入简单,分析功能易用,报告简洁没有用户细分数据,也不支持用户路径分析,功能较为单一
Matomo对标 Google Analytics 的功能,接入简单,功能强大,分析维度丰富,支持私有化部署,包括代码和数据都可以私有化处理,有强大的插件机制,可以自行开发功能私有化需要自行部署和维护服务器、数据库等,部分分析功能需要二次开发

通过对比,Matomo 整体功能比较强大,对标了 Google Analytics 但在安全性和私密性方面更优,支持私有化部署,代码和数据都可以不透露给第三方,并且可以通过插件的机制配合业务实现自定义,这些优点都是我们最终选择 Matomo 作为「内容管理平台」用户记录的工具的原因。

Matomo 是什么?

这里介绍一下 Matomo,作为一套基于 PHP 与 MySQL 的网页流量统计和分析平台,它的大部分功能已经开源,并且做了很好的封装,可以轻松地进行私有化部署,它的功能主要分成两块:

  • 收集并存储页面访问数据,主要是用户信息,如设备型号、分辨率、用户地区、来源,以及页面信息,如页面访问路径、访问操作等。
  • 对收集起来的数据进行指标量化并可视化的展示,例如用户设备型号分布、地区分布、某个页面的浏览人数、访问最多的页面、某个用户在某个页面的访问路径和具体操作等,并且在收集数据时,Matomo 会有大量的策略保护用户隐私,例如上报 IP 时隐藏最后一位字节等。

在实际使用时,用户信息的上报以及页面的访问路径,只需要安装并引入 Matomo 即可实现,无需额外的配置。但是开发者可以通过接口增强上报的数据,例如上报某个弹窗的展示,或者上报某个请求的结果,这样最终可以在平台上展示出完整的用户访问路径和操作,结合业务日志,可以很准确地定位问题以及还原问题的触发路径。

Matomo 落地到业务

在引入 Matomo 之前,先说明一下 Matomo 的主要组成追踪器和 Matomo 服务端,追踪器基于 JS 实现,需要在网页引入,用于上报数据。服务端主要提供了三个功能:

  • HTTP 接口,追踪器可以收集所在网页的数据但不上报,通过 HTTP 接口发送给 Matomo。
  • 归档任务运行并预处理数据,默认分为实时动态处理(页面访问数据,用户访问轨迹)和 cron 任务处理(用户维度的列表)。
  • 可视化展现数据,也可以数据接口或者报表接口来访问这些数据。

引入简单落地不易

Matomo 有很成熟封装,因此本身部署很简单,主要分为两个步骤:

  1. 部署私有化 Matomo 服务。
  2. 在需要流量统计ide页面上引入追踪器。

其中部署私有化服务只需要下载 Matomo 的程序并上传到服务端,然后打开访问地址就可以使用引导程序部署服务,包括检测服务器环境是否符合要求,填写数据库信息,创建管理账号等,具体参考官方文档

但在实际落地到内容平台的过程中,却遇到了问题——我们需要基于 Docker 进行部署。

由于业务的部署都基于 Docker 和 k8s 进行,因此私有化的 Matomo 也需要基于此进行部署,这样会带来几个问题:

  1. Matomo 的设置分成系统配置与功能设置,其中功能设置储存在 MySQL 中,而系统设置则储存在本地的配置文件中,当部署多个容器时,配置无法对齐,另外 Docker 重新部署后,这些配置修改也会丢失。
  2. 这套部署需要域名 + 路径的形式访问 Matomo,Matomo 社区镜像中是使用 Apache2 进行路由处理的,而 Apache2 默认的配置并不适配路径,需要修改 Apache 的配置文件。

解决在 Docker 中部署 Matomo 的问题

Matomo 有官方发布的社区镜像可以直接使用,但为了解决上述的问题,需要在构建 Docker 镜像时进行额外的处理。

解决配置丢失的问题

Matomo 的配置文件是 config/config.ini.php,不跟随版本管理,为了获取一份默认的配置文件,可以用社区镜像预先部署好一个 Matomo 容器,并在容器中获取一份默认的配置文件,例如:

[database]
host = "${MATOMO_DATABASE_HOST}"
username = "${MATOMO_DATABASE_USERNAME}"
password = "${MATOMO_DATABASE_PASSWORD}"
dbname = "${MATOMO_DATABASE_DBNAME}"
tables_prefix = "${MATOMO_DATABASE_TABLES_PREFIX}"
charset = "utf8mb4"
multi_server_environment = 1
enable_installer = 0

[General]
force_ssl = 0
assume_secure_protocol = 1
proxy_client_headers[] = "HTTP_X_FORWARDED_FOR"
proxy_client_headers[] = "HTTP_X_ORIGINAL_FORWARDED_FOR"
proxy_host_headers[] = "HTTP_X_FORWARDED_HOST"
salt = "xxxx" // 加密串,用于解密配置内容
trusted_hosts[] = "weread.qq.com"

[Plugins]
Plugins[] = "CorePluginsAdmin"
// ...
// 需要启动的插件列表,由于篇幅有限,省略默认的启动插件

[PluginsInstalled]
PluginsInstalled[] = "Diagnostics"
// ...
// 所有插件列表,,由于篇幅有限,省略默认的插件列表

复制出默认的配置文件后,即可根据业务进行修改,主要包括:

  1. 数据库的配置,建议使用环境变量进行配置。
  2. salt 是用于解密配置内容的加密串,保留默认配置中的值即可。
  3. trusted_hosts[] 是部署 Matomo 的域名,支持多个域名配置,必须正确填写,否则无法使用。
  4. Plugins[]PluginsInstalled[] 分别是需要启用的插件和总插件列表,有需要调整插件的激活状态可以自行调整。

在修改完成后,可以利用 Docker 的命令把自定义的配置文件覆盖到镜像中,例如:

# 复制配置文件
COPY config.ini.php /var/www/html/config/config.ini.php

解决子目录部署的问题

Matomo 部署完成后,会以 weread.qq.com/weread-matomo 的形式去访问 Matomo 的服务,因此根据默认的 Apache2 配置,会尝试在 weread-matomo 这个目录中读取 Matomo,但实际上我们的 Matomo 是部署在根目录的,因此需要修改 Apache2 的配置文件,把针对 ^/weread-matomo 的访问指向根目录。

值得注意的是,出于安全考虑,我们不希望把 Matomo 的管理后台暴露到外网,因此在 Apache2 的配置中,可以通过正则指定只有追踪器相关的文件暴露到外网可以访问,方便业务引入。在了解了 Matomo 的源码后,追踪器相关的文件主要有 matomo.jsmatomo.php,其中 matomo.php 结尾会带有参数,因此最终的 AliasMatch 规则如下:

<VirtualHost *:80>
    ServerAdmin webmaster@localhost
    DocumentRoot /var/www/html

    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined

    AliasMatch ^/weread-matomo/(matomo\.(js|php).*) /var/www/html/$1

    <Directory /var/www/html>
        Options All
        AllowOverride All
        order allow,deny
        allow from all
    </Directory>
</VirtualHost>

社区镜像中 Apache2 的配置文件存放在 /etc/apache2/sites-available/000-default.conf,把上面的配置内容在本地保存一份 000-default.conf 后,在构建 Docker 镜像时利用命令覆盖默认的 Apache2 配置:

COPY 000-default.conf /etc/apache2/sites-available/000-default.conf

无法显示城市信息

在解决了上面两个问题后,Matomo 的私有化部署基本已经跑通了,但后续我们发现,这样上报的数据中,并没有显示城市信息,Matomo 是基于 IP 信息来判定城市的,而 Matomo 自带的 IP 库仅能识别国家信息。

访客地图

如上图所示,城市都显示为“未知”。为了解决这个问题,需要引入 IP 地址库,Matomo 支持 DBIP 和 GeoIP 2 两个外部的地址库,地址库的格式都是一种特殊的地址库 .mmdb 格式。

这里建议使用 GeoIP,在这里完成注册后,可以下载 .mmdb 格式的地址库。为了让 Matomo 识别出额外的地址库,需要把 .mmdb 放置到项目的 misc 目录,但由于 Matomo 使用了 Docker 部署,因此需要用 Docker 命令把 .mmdb 文件复制到容器的 misc 目录,最终完整的 Dockerfile 如下:

# Dockerfile

FROM matomo:latest

MAINTAINER kayoli

# 复制 Apache2 配置文件
COPY 000-default.conf /etc/apache2/sites-available/000-default.conf

# 复制 Matomo 配置文件
COPY config.ini.php /var/www/html/config/config.ini.php

# 引入 IP 地址库,用于显示 IP 对应的城市
COPY mmdb/GeoLite2-ASN.mmdb /var/www/html/misc/GeoLite2-ASN.mmdb
COPY mmdb/GeoLite2-City.mmdb /var/www/html/misc/GeoLite2-City.mmdb
COPY mmdb/GeoLite2-Country.mmdb /var/www/html/misc/GeoLite2-Country.mmdb

前端引入追踪器也有坑

页面引入追踪器

经过上面的处理,已经解决了在 Docker 中部署服务端的问题,在 Matomo 的部署引导程序在完成后,会输出一段 JS 代码,用于给业务前端引入追踪器,例如:

<!-- Matomo -->
<script type="text/javascript">
  var _paq = window._paq = window._paq || [];
  /* tracker methods like "setCustomDimension" should be called before "trackPageView" */
  _paq.push(['trackPageView']);
  _paq.push(['enableLinkTracking']);
  (function() {
    var u="//xxxx"; // 私有化部署 Matomo 的域名
    _paq.push(['setTrackerUrl', u+'matomo.php']);
    _paq.push(['setSiteId', '1']);
    var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
    g.type='text/javascript'; g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
  })();
</script>
<!-- End Matomo Code -->

Matomo 的追踪器包含了大量的选项和方法,主要包括:

  1. Tracker Object,用于记录某个行为,例如上面代码中的 trackPageView 则用于记录某个页面被访问,enableLinkTracking 则是用于开启链接跳转时自动记录的功能。
  2. Configuration of the Tracker Object,用于配置 Tracker Object,例如 setDocumentTitle 可以覆盖上报页面的标题,默认是获取 document.title
  3. Ecommerce,电商相关的方法,提供了一系列记录商品信息的方法。
  4. Managing Consent,提供了一种机制来管理用户的跟踪上报。

具体可以参考文档

自动记录 Vue SPA 的页面跳转

成功引入追踪器后发现,「内容管理平台」上报的用户行为,只有打开页面的操作,跳转页面并没有成功上报,但是默认的追踪器代码中,已经开启了 `` 选项。

在 Matomo 的源码中,可以看到对 `` 的说明:

 // @param bool enable Defaults to true.
 //    If "true", use pseudo click-handler (treat middle click and open contextmenu as
 //    left click). A right click (or any click that opens the context menu) on a link
 //    will be tracked as clicked even if "Open in new tab" is not selected.
 //      If "false" (default), nothing will be tracked on open context menu or middle click.
 //    The context menu is usually opened to open a link / download in a new tab
 //    therefore you can get more accurate results by treat it as a click but it can lead
 //    to wrong click numbers.
 //
this.enableLinkTracking = function (enable) {
    linkTrackingEnabled = true;
    // ...
};

也就是说,这个选项仅对 link,也就是常见的 <a href="xxxx">链接</a> 这种形式的跳转才起作用,而「内容管理平台」是基于 Vue 开发的 Spa,页面跳转不是链接跳转,因此上报的记录里只有打开页面。

要解决这个问题,可以在 Vue 进行跳转时主动调用 Matomo 的上报,但实际上已经有开源的插件实现了这个,例如vue-matomo,具体使用可以参考它的使用文档

值得注意的是,vue-matomo 对 matomo 的初始参数进行封装,除了文档中列出来的选项,其他选项在初始化的时候是无效的,可以在 vue-matomo 初始化后,通过 _paq.push(['xxx']) 调用,_paq 对象的 push 方法已经被重写,调用 push 方法实际上相当于把某个方法放入调用队列并进行调用。

Matomo 的最佳实践

经过上面的踩坑和填坑后,Matomo 最终得以在「内容管理平台」中落地投入使用,在经过一段时间的实践后,现有的自动记录还是不能满足我们的需求,例如我们需要自动上报 JS 错误信息,在点击 UI 元素时也需要上报,另外还需要在请求错误时进行自动上报。在经过一系列实践后,总结了一些最佳实践。

自动记录 JS 错误

在新版 Matomo 中,支持开启自动上报 JS 错误的功能,但功能尚未正式发布,因此官方文档中没有该功能的说明,需要调用的话可以通过 window._paq.push(['enableJSErrorTracking']); 开启该功能,为了保护 _paq 没有初始化好的情况,可以先判断 _paq 是否存在,例如:

const enableJSErrorTracking = (): void => {
  if (window._paq) {
    window._paq.push(['enableJSErrorTracking']);
  } else {
    console.warn('can not found window._paq');
  }
};

主动上报更多操作

除了链接跳转,页面中通常还会有一些 UI 操作不涉及链接变化,也不涉及请求,这类操作可以使用追踪器提供的 Tracker Object 进行主动上报,为了方便起见,可以抽取成工具方法,例如:

// 上报一个事件,例如点击事件,播放事件等,在主动上报中比较常用。
export const trackEvent = (category: string, action: string, name?: string, value?: number): void => {
  if (window._paq) {
    window._paq.push(['trackEvent', category, action, name, value]);
  } else {
    console.warn('can not found window._paq');
  }
};

// 二次封装,专门上报弹窗的动作,例如 action 参数可以填写 show, close
export const trackDialogEvent = (action: string, name?: string, value?: number): void => {
  if (window._paq) {
    window._paq.push(['trackEvent', 'Dialog', action, name, value]);
  } else {
    console.warn('can not found window._paq');
  }
};

// 上报错误
export const trackErrorEvent = (action: string, name?: string, value?: number): void => {
  if (window._paq) {
    window._paq.push(['trackEvent', 'Error', action, name, value]);
  } else {
    console.warn('can not found window._paq');
  }
};

// 上报搜索动作
export const trackSiteSearch = (keyword: string, category?: string, resultsCount?: string): void => {
  if (window._paq) {
    window._paq.push(['trackSiteSearch', keyword, category, resultsCount]);
  } else {
    console.warn('can not found window._paq');
  }
};

请求失败自动上报

业务中涉及请求的操作通常都比较关键,请求失败自动上报有利于记录下完整的用户动作路径,方便定位问题,在我们的业务中,我们的请求都是使用 Axios 发出的,因此可以利用 axios interceptors 劫持所有请求,在遇到指定错误时自动上报到 Matomo,例如:

const baseURL = 'xxx';
// axios instance
const service = axios.create({
  baseURL,
  timeout: 60000,
});

service.interceptors.response.use(
  (response: AxiosResponse) => {
    const errCode = response.data && (response.data.errCode || response.data.errcode);
    if (errCode && errCode < 0) {
      const URL = response.config && response.config.url;
      const errMsg = response.data && (response.data.errMsg || response.data.errMsg);
      trackErrorEvent(URL, errMsg, errCode);
    }
    return response;
  },
  (error) => {
    return Promise.reject(error);
  }
);

至此,已经可以很准确展示用户在访问页面时的完整操作路径了,开发者可以通过这些操作路径,结合业务日志,方便地去定位问题以及还原问题。

完整记录用户操作的示意图

效果展示

经过以上的处理,现在已经可以上报非常丰富的访问数据,以及用户路径了,例如:

访客分析 - 访问日志

访问日志

可以看到,界面上显示了完整的用户操作,通过时间轴的形式,配合不同的关键词和 icon 可以很好地呈现出实际的操作路径。

访客分析 - 设备

访客分析设备列表

除了设备,在 Matomo 中还有地区等用户维度,并且 Matomo 在不同的数据展示中,例如目标转化率等,都可以基于这些不同的维度进行展示,对于分析用户组成相当方便。

转化与收益分析 - 概览

Matomo 支持设定指定的目标,用于计算转化率,并进行多个维度的展示,包括转化流向,每个阶段的转化人数和转化率等,并且可以通过不同的维度,例如渠道类型、城市、设备类型分别展示各种维度下的转化数据,这也是 Matomo 一种重要的特性,数据的展示维度丰富。

转化与收益分析

另外,Matomo 的插件机制也非常强大,可以插入自定义的数据,注入到各个界面或者基于 Matomo 自身收集的数据重新展示,基于篇幅所限,后续再对 Matomo 的插件机制进行实践说明。

Matomo 的性能分析与局限性

从上面的说明中可以看出,Matomo 的分析功能强大,分析的维度也很丰富,但同时也带来了较大的服务端资源消耗。

Matomo 的架构可以支撑千万级甚至亿级的月 PV,但同时对于服务端的 CPU,RAM 和硬盘空间都有相应的要求。因此在实际使用时,需要注意当前服务端的配置是否足以支撑上报页面的 PV 量,否则会导致 Matomo 无法及时处理数据甚至崩溃。

量化性能分析

  • Matomo 的默认配置是 1GB 内存,在默认配置下,Matomo 可以轻松支撑 1000 PV/天的访问量,对于这种级别的访问量,通常是一些内部平台或者面向特定人群的辅助页面。
  • 对于 3000 PV/天访问量的业务,则建议使用2核 CPU,2GB RAM,50GB 硬盘的配置。
  • 对于 30000 PV/天访问量的业务,则建议使用4核 CPU,8GB RAM,250GB 硬盘的配置,这个量级的访问可以是面向大众用户的业务页面了。
  • 对于 300000 PV/天访问量的业务,建议把 PHP 服务端和 MySQL 分开部署,对于这种量级的业务,MySQL 的瓶颈会更加明显,把 MySQL 部署进行单独部署,会更加稳定,建议最低的配置是8核 CPU,16GB RAM, 100GB 硬盘的机器作为 PHP 服务端,8核 CPU, 16GB RAM, 400GB 硬盘作为 MySQL 服务,
  • 对于更高访问量的业务,可以再叠加机器配置,硬盘空间主页是给 MySQL 消耗用的,一个参考数据是:大概每增加500万PV,数据库就会增加1GB的数据。

可以看到,对于高访问量的业务,需要给予 Matomo 大量的服务器资源才能支撑,具体来说,超过 30000 PV/天的业务就需要注意了。因此在业务中引入时,可以考虑业务引入上报的必要性,如果页面功能单一,操作路径少,例如展示型的 H5 页面,其实引入 Matomo 的作用不是很大。

优化 Matomo 的性能的最佳实践

  • Matomo 对于 PHP 的最低版本要求是 PHP5,但尽量使用 PHP7,PHP7 在性能上有大量的优化。
  • 使用 PHP cache,PHP5 及以上版本默认开启了。
  • 通过调整 Innodb 配置来优化 MySQL 的性能,例如增加 innodb_buffer_pool_size 来适应内存大小,另外可以把 innodb_buffer_pool_size 设置为 MySQL 可用内存的80%。增大 innodb_flush_log_at_trx_commit 来增加追踪器的吞吐量,具体可以参考这里
  • 业务访问量比较大的时候(例如 300000 PV/天的访问量),可以关闭实时动态处理的功能(管理 - 系统 - 通用设置 - 归档设置 - 在浏览器中查看报告时进行归档 - 否),关闭实时动态处理后,页面访问数据和用户访问轨迹也需要等待 cron 任务进行数据处理后才能展示,归档时间建议是设置为 3600 秒,减轻服务端的负担。
  • 对于 URL 带 Query 的情况,如果无需要区分 Query 进行数据分析的情况,可以选择忽略这些 Query(管理 - 网站 - 管理 - 编辑网站 - 排除参数),否则同一个 URL 带有不同 Query,Matomo 会当作不同的 URL 来处理,大大增加 MySQL 的负担。
  • 定期删除旧数据(管理 - 隐私设置 - 匿名化数据 - 定期删除旧的原始数据),旧数据的删除可以减小数据库的大小,既节省硬盘空间,也加快了数据的处理。

]]>
https://kayosite.com/matomo-best-practices.html/feed 9 3585
探索巴伐利亚——中世纪世界的记录 https://kayosite.com/10-days-bavaria-and-austria-road-trip.html https://kayosite.com/10-days-bavaria-and-austria-road-trip.html#respond Wed, 10 Jun 2020 12:47:00 +0000 https://kayosite.com/?p=3709 去年夏天这个时候,广州又是每年的雨季,经过14个小时的颠簸飞往了法兰克福,然后就根据之前设计的环线路程,自驾从法兰克福开始,一路探索了巴伐利亚地区,从南部再去到奥地利,再从奥利地中部回到法兰克福。

对于巴伐利亚的印象,基本停留在《世界通史》里介绍的模样——遍地的石板路,沧桑但坚实,多年的磨砺使得路面高低不平,城市里遍布城堡,同样古老但仍旧不倒。

来都来了,对于中世纪历史感兴趣的我,自然要把看到的记录下来。

]]>
https://kayosite.com/10-days-bavaria-and-austria-road-trip.html/feed 0 3709
Web 可访问性与无障碍最佳实践 https://kayosite.com/web-accessibility-best-practice.html https://kayosite.com/web-accessibility-best-practice.html#respond Thu, 07 Mar 2019 12:33:00 +0000 https://kayosite.com/?p=3565 Web 无障碍开发知识

对于 Web 开发者来说,可以通过调整 HTML 的结构和标签,增加 HTML 属性,配合 CSS 和 JavaScript 等手段来提高页面的可访问性和无障碍性。例如使用了 a 标签制作了按钮,如果不进行额外的优化,读屏软件在朗读时会读作"文字内容 链接",但实际上该 a 标签是用作按钮使用,因此可以在标签上添加 role="button" 属性。此时,读屏软件会读作"文字内容 按钮"。

可以看到,读屏软件在朗读时会在结尾朗读出元素的属性,这也是无障碍优化中重要的一环。无障碍优化就是要解决如何使得元素的属性被正确识别,如何使得元素的内容被清晰准确地朗读,如何排除干扰元素等问题。在了解 WeRead H5 的无障碍优化处理之前,首先需要了解 Web 无障碍开发的基础知识,及读屏软件的工作方式(以 Apple VoiceOver 为例),可以参考以下资料:

WeRead H5 的开发细则

我们参考 WCAG 2.1,在微信读书 H5 项目中总结了一些无障碍开发的最佳实践。

DOM 的顺序很重要

读屏软件在读屏时默认按照 DOM 的顺序朗读,因此如果 DOM 的顺序与内容的语义顺序不一致,例如使用了 flex-direction: row-reverse; 使得内容的顺序倒序显示,会使得内容难以理解。因此尽量避免使用会影响到 DOM 视觉顺序的样式,如果无法避免,需要手动设置 tabIndex 属性,告知读屏软件正确的内容顺序。

为非文本元素提供文本说明

图像使用 alt 属性描述图像内容

<img> 标签需要加上 alt 属性,读屏软件会自动读出 alt 的内容,例如 alt 内容为"一只目光汹汹凝视远方的猫",那么会被读作"一只目光汹汹凝视远方的猫 图像"。如果没有添加 alt 属性,那么仅会读作"图像",视障用户会完全无法理解其实际含义。

但是,当 <img> 标签出现在 <a> 标签内部,作为一个图像链接时,应在 <a> 上使用 title 属性,<img> 标签可不加 alt 属性。

视频使用 title 属性

与上面的 <img> 标签相似,<video> 标签需要加上 title 属性,例如 title 内容为"一只正在奔跑的猫",那么会被读作一只正在奔跑的猫 视频"。

使用语义化的元素

尽量使用语义化标签

语义化的 HTML 标签,例如 <header> <footer> <nav> <section> <main> <aside> <button>,使用语义化的标签,主要影响两个方面:

  • 选中元素时是否会整块选中
  • 朗读时结尾会加上怎样的修饰词

其中默认设置下,目前仅 <button> 标签可以使得选中元素时会整块选中,而不单独选中子元素。至于修饰词这里列举具体的情况:

  • <header> 读作"xxx 横幅 标志性内容"。
  • <footer> 读作"xxx 页脚 标志性内容"。
  • <nav> 读作"xxx 导航 标志性内容"。
  • <section> 仅读作"xxx",没有结尾修饰词。
  • <main> 读作"xxx 主要 标志性内容"。
  • <aside> 读作"xxx 补充 标志性内容"。
  • <button> 读作"xxx 按钮"。
  • <a> 读作"xxx 链接"。

实际上,在浏览器内部,使用语义化标签会隐式加上特定的 role 属性,最后朗读时的结尾修饰词也正是这些 role 属性的值以及分类,其他 role 的值朗读时也可以以此类推,而以上标签与 role 属性具体对应的关系如下:

HTML 标签role 属性值
headerbanner
footercontentinfo
navnavigation
sectionregion
mainmain
asidecomplementary
buttonbutton
alink

以上 role 属性值的分类大多数都属于标志性内容

role 属性

如果出于其他考虑,使用了非对应语义的标签,例如开头提到的使用 a 标签实现按钮,就需要添加 role="button" 属性来声明这是一个按钮。同理,其他类似情况也可以这样处理,主要的就是影响朗读时的修饰词。

禁用状态使用 disabled 属性

使用特定的 class 来增加禁用态样式是常见的手法,但由于 class 语义并不能被读屏软件识别,因此读屏时无法知道当前处于禁用态。可以改为使用 disabled 属性实现禁用态,例如:

<input type="search" name="q" placeholder="请输入用户名" aria-label="搜索用户" disabled/>
/* 禁用态样式 */
input[disabled] {
    opacity: .5;
}

会读作 搜索用户 请输入用户名 变暗 搜索栏,读屏软件会用"变暗"这个词表示搜索栏处于不可用的状态。而对于没有 disabled 属性的标签,例如 a 标签,可以使用 aria-disabled 属性达到同样的效果。

可使用 aria 标签向不存在原生语义的元素添加语义

  • aria-label="screen reader only label",用于添加朗读时的描述,读屏时会读出其中的内容,而忽略标签的原有的文字,例如为 a 标签同时添加 role="button"aria-label="额外的按钮描述",最终会朗读成"额外的按钮描述 按钮"。
  • aria-controls="main",用于给操作按钮关联控制区域,VoiceOver 上这个属性没有任何作用,但 PC 读屏软件中,添加了该属性后,可以把焦点从按钮快速移动到被控制区域。
  • aria-live="true",添加了该属性的元素,在其内容发送变化时,读屏软件会自动读出变化后的新内容。可以用于会动态刷新的元素,例如发现卡片上的“XXX人参与活动”,书城的换一批功能,用于监听实时变化的数据。实际效果可以参考这个 demo
  • 更多属性详见:https://www.w3.org/TR/wai-aria/

动画

可在 iOS 下通过 CSS 选择器 @media(prefers-reduced-motion) 来针对开启了“避免动画”的用户取消动画。

隐藏屏幕外的元素

确保屏幕外的内容已通过 display: nonevisibility: hidden 隐藏(如浮动出现的 alert 和 banner 等),如没有隐藏,读屏软件仍会读出元素内容,但屏幕外的元素通常不希望被读出,如果不方便使用样式进行隐藏,可以为元素添加 aria-hidden="true" 属性,元素则会被读屏软件忽略。

常用场景

图像的编写

如上文所述,图像需要补充文字描述,补充时需要使用具体的内容标题,例如书籍封面,可以使用书籍名称,而不要直接统一描述为"书籍封面",同理用户头像也应该使用用户名作为描述文字。

按钮的编写

在 H5 中,为了避免一些浏览器默认样式的干扰,以及制作点击效果(具体原因),目前采用 a 标签实现。但从无障碍的角度考虑,a 标签默认会被当做链接处理,读屏时会读作"链接内的文字 链接"。

基础无障碍适配

需要加上 aria="button" 属性,例如:

<a class="test_btn" role="button" href="javascript:;">文字</a>

读屏时会读出"文字 按钮"。

增加描述文字

如果 a 标签内本身没有文字,例如以图片、背景色和边框制作的按钮,还需要加上 aria-label="描述文字",读屏时会读作"描述文字 按钮"的形式。当 a 标签内的文字对于视障人士不足以描述清楚按钮作用时(例如需要结合上下的元素,或者结合按钮本身的背景图才能理解按钮的含义时),也可以加上 aria-label 属性,aria-label 的内容会被优先读出,例如:

<a class="test_btn" role="button" href="javascript:;" aria-label="更完整的描述">文字</a>

多重标签嵌套

另外 a 标签内容如果有嵌套的标签,并不会影响文字被读出,例如:

<a class="test_btn" role="button" href="javascript:;">
    <span class="test_btn_inner">
        <span class="test_btn_inner_text">文字</span>
    </span>
</a>

读屏时仍会读出"文字 按钮"。

整块可点击元素的编写

在遇到 banner 等本身由多个子元素组成,但点击时为整块点击的元素,需要分为两种情况考虑:

使用 a 标签实现

如果使用了默认的点击效果,即使用了 a 标签实现外层框,读屏时子元素会被分别选中,但实际上单独读出每个子元素不能表达按钮整体的完整含义。因为,我们建议整块当作按钮处理,但一般无需添加 aria-label,让读屏软件直接按 DOM 顺序读出子元素的文字内容即可,例如:

<a class="welcomeBonus_packet" href="javascript:;" role="button" @click="packetRedeem">
    <div class="welcomeBonus_packet_info">
        <div class="welcomeBonus_packet_info_title">主标题内容文字</div>
        <div class="welcomeBonus_packet_info_desc">描述文字</div>
    </div>
    <div class="welcomeBonus_packet_btn">
        <span>提示文字</span>
    </div>
</a>

会被读作"主标题内容文字 描述文字 提示文字 按钮",视障人士会清楚这是整体点击的按钮,并且了解到其作用。如果部分内容不希望被读出来,精简朗读文案的时长,例如作用不大的辅助语句,可以单独添加 aria-hidden="true"

需要注意,可点击元素点击后跳转页面通常采用 role="link" 声明,而点击后进行一些操作则通常采用 role="button" 声明,读屏的时候结尾分别为"链接"和"按钮",但本场景下建议统一使用 role="button",因为 role="link" 并不会让元素整块被识别,实际体验上,整体识别能带来更好的体验,而视障人士对于"链接"和"按钮"的理解包容度也比较高。

使用语义化标签实现

如果无需使用默认的点击效果,建议使用语义化的标签实现外层框,例如 sectionaside,这样用户在使用“container 模式”进行读屏时,元素会直接被整体识别,而不会单独读出子元素。

以 VoiceOver 为例,双指旋转可以调节焦点选择的模式,”container 模式“下焦点仅会被 section 这类外层容器捕捉。

小程序注意事项

  • 小程序中目前仅支持 aria-role(相当于原生 Web 的 role)和 aria-label 两个属性,如果读屏时需要忽略某些元素,无法使用 aria-hidden 来声明,因此需要注意尽量让无需被读屏的元素不输出 DOM。
  • 小程序中没有语义标签,因此整块点击的元素只能加上 aria-role="button"

]]>
https://kayosite.com/web-accessibility-best-practice.html/feed 0 3565
Android Lint 实践之二 —— 自定义 Lint https://kayosite.com/android-lint-custom-issue.html https://kayosite.com/android-lint-custom-issue.html#respond Mon, 16 Oct 2017 05:29:00 +0000 https://kayosite.com/?p=3562 背景

如前文《Android Lint 实践 —— 简介及常见问题分析》所述,为保证代码质量,团队在开发过程中引入了 代码扫描工具 Android Lint,通过对代码进行静态分析,帮助发现代码质量问题和提出改进建议。Android Lint 针对 Android 项目和 Java 语法已经封装好大量的 Lint 规则(issue),但在实际使用中,每个团队因不同的编码规范和功能侧重,可能仍需一些额外的规则,基于这些考虑,我们研究并开发了自定义的 Lint 规则。

基础

创建自定义 Lint 需要创建一个纯 Java 项目,引入相关的包后可以基于 Android Lint 提供的基础类编写规则,最终把项目以 jar 的形式输出后就可以被主项目引用。这里我们以 QMUI Android 中的一个实际场景来说明如何进行自定义 Lint:我们在项目中使用了 Vector Drawable,在 Android 5.0 以下版本的系统中,Vector Drawable 不被直接支持,这时使用 ContextCompat.getDrawable() 去获取一个 Vector Drawable 会导致 crash,而这种情况由于只在 5.0 以下的系统中才会发生,往往不易被发现,因此我们需要在编写代码的阶段就能及时发现并作出提醒。在 QMUI Android 中,提供了 QMUIDrawableHelper.getVectorDrawable 方法,基于 support 包封装了安全的获取 Vector Drawable 的方法,因此我们最终的需求是检查出所有使用 ContextCompat.getDrawable()getResources().getDrawable() 去获取 Vector Drawable 的地方,进行提醒并要求替换为 QMUIDrawableHelper.getVectorDrawable 方法。

创建工程

如上面所述,创建自定义 Lint 需要创建一个 Java 项目,项目中需要引入 Android Lint 的包,项目的 build.gradle 如下:

apply plugin: 'java'

configurations {
    lintChecks
}

dependencies {
    compile "com.android.tools.lint:lint-api:25.1.2"
    compile "com.android.tools.lint:lint-checks:25.1.2"

    lintChecks files(jar)
}

jar {
    manifest {
        attributes('Lint-Registry': 'com.qmuiteam.qmui.lint.QMUIIssueRegistry')
    }
}

其中 lint-api 是 Android Lint 的官方接口,基于这些接口可以获取源代码信息,从而进行分析,lint-checks 是官方已有的检查规则。Lint-Registry 表示给自定义规则注册,以及打包为 jar,这个下面会详细解释。

Detector

Detector 是自定义规则的核心,它的作用是扫描代码,从而获取代码中的各种信息,然后基于这些信息进行提醒和报告,在本场景中,我们需要扫描 Java 代码,找到 getDrawable 方法的调用,然后分析其中传入的 Drawable 是否为 Vector Drawable,如果是则需要进行报告,完整代码如下:

/**
 * 检测是否在 getDrawable 方法中传入了 Vector Drawable,在 4.0 及以下版本的系统中会导致 Crash
 * Created by Kayo on 2017/8/24.
 */

public class QMUIJavaVectorDrawableDetector extends Detector implements Detector.JavaScanner {

    public static final Issue ISSUE_JAVA_VECTOR_DRAWABLE =
            Issue.create("QMUIGetVectorDrawableWithWrongFunction",
                    "Should use the corresponding method to get vector drawable.",
                    "Using the normal method to get the vector drawable will cause a crash on Android versions below 4.0",
                    Category.ICONS, 2, Severity.ERROR,
                    new Implementation(QMUIJavaVectorDrawableDetector.class, Scope.JAVA_FILE_SCOPE));

    @Override
    public List<String> getApplicableMethodNames() {
        return Collections.singletonList("getDrawable");
    }

    @Override
    public void visitMethod(@NonNull JavaContext context, AstVisitor visitor, @NonNull MethodInvocation node) {

        StrictListAccessor<Expression, MethodInvocation> args = node.astArguments();
        if (args.isEmpty()) {
            return;
        }

        Project project = context.getProject();
        List<File> resourceFolder = project.getResourceFolders();
        if (resourceFolder.isEmpty()) {
            return;
        }

        String resourcePath = resourceFolder.get(0).getAbsolutePath();
        for (Expression expression : args) {
            String input = expression.toString();
            if (input != null && input.contains("R.drawable")) {
                // 找出 drawable 相关的参数

                // 获取 drawable 名字
                String drawableName = input.replace("R.drawable.", "");
                try {
                    // 若 drawable 为 Vector Drawable,则文件后缀为 xml,根据 resource 路径,drawable 名字,文件后缀拼接出完整路径
                    FileInputStream fileInputStream = new FileInputStream(resourcePath + "/drawable/" + drawableName + ".xml");
                    BufferedReader reader = new BufferedReader(new InputStreamReader(fileInputStream));
                    String line = reader.readLine();
                    if (line.contains("vector")) {
                        // 若文件存在,并且包含首行包含 vector,则为 Vector Drawable,抛出警告
                        context.report(ISSUE_JAVA_VECTOR_DRAWABLE, node, context.getLocation(node), expression.toString() + " 为 Vector Drawable,请使用 getVectorDrawable 方法获取,避免 4.0 及以下版本的系统产生 Crash");
                    }
                    fileInputStream.close();
                } catch (Exception ignored) {
                }
            }
        }
    }
}

QMUIJavaVectorDrawableDetector 继承于 Detector,并实现了 Detector.JavaScanner 接口,实现什么接口取决于自定义 Lint 需要扫描什么内容,以及希望从扫描的内容中获取何种信息。Android Lint 提供了大量不同范围的 Detector

  • Detector.BinaryResourceScanner 针对二进制资源,例如 res/raw 等目录下的各种 Bitmap
  • Detector.ClassScanner 相对于 Detector.JavaScanner,更针对于类进行扫描,可以获取类的各种信息
  • Detector.GradleScanner 针对 Gradle 进行扫描
  • Detector.JavaScanner 针对 Java 代码进行扫描
  • Detector.ResourceFolderScanner 针对资源目录进行扫描,只会扫描目录本身
  • Detector.XmlScanner 针对 xml 文件进行扫描
  • Detector.OtherFileScanner 用于除上面6种情况外的其他文件

不同的接口定义了各种方法,实现自定义 Lint 实际上就是实现 Detector 中的各种方法,在上面的例子中,getApplicableMethodNames 的返回值指定了需要被检查的方法,visitMethod 则可以接收检查到的方法对应的信息,这个方法包含三个参数,其作用分别是:

  • context 这里的 context 是一个 JavaContext,主要的功能是获取主项目的信息,以及进行报告(包括获取需要被报告的代码的位置等)。
  • visitor visitor 是一个 ASTVisitor,即 AST(抽象语法树)的访问者类,Android Lint 把扫描到的代码抽象成 AST,方便开发者以节点 - 属性的形式获取信息,visitor 则可以方便地获取当前节点的相关节点。
  • node 这是一个 MethodInvocation 实例,MethodInvocation 是 Android Lint 里的 AST 子类,在上面的例子中,node 表示的是被扫描到的方法,所以我们可以通过节点 - 属性的形式获取被扫描的方法的参数等各种信息。

在例子中我们获取方法的参数,通过遍历参数拿到 Drawable 参数,分解出 Drawable 的文件名,然后通过 context 获取主项目的资源路径,配合 Drawable 的文件名拼接文件的实际路径,确定文件存在后检查文件内容开头是否包含 “vector” 这个字符串,如果是则表示开发者在普通的 getDrawable 方法中传入了 Vector Drawable,最后调用 context 的 report 方法进行报告。

值得注意的是,在例子中我们并没有直接实例 Drawable,然后通过 Drawable 的方法判断是否为 Vector Drawable,而是通过较为繁琐的步骤检查文件内容,这是因为 Android Lint 的项目是一个纯 Java 项目,不能使用 android.graphics 等包,因而开发时会比较繁琐。

Issue

在上面的例子中,在检查出问题需要进行报告时,context.report 方法中传入了一个 ISSUE_JAVA_VECTOR_DRAWABLE,这里的"issue"是声明一个规则,因此自定义一个 Lint 规则就需要定义一个 issue。issue 由类方法 Issue.create 创建,参数如下:

  • id:标记 issue 的唯一值,语义上要能简短描述问题,使用 Java 注解和 XML 属性屏蔽 Lint 时,就需要使用这个 id。
  • summary:概况地描述问题,不需要给出解决办法。
  • explanation:详细地描述问题以及给出解决办法。
  • category:问题类别,在系统给出的分类中选择,后面会详述。
  • priority:1-10 的数字,表示优先级,10 为最严重。
  • severity:严重级别,在 Fatal,Error,Warning,Informational,Ignore 中选择一个。
  • Implementation:Detector 与 Issue 的映射关系,需要传入当前的 Detector 类,以及扫描代码的范围,例如 Java 文件、Resource 文件或目录等范围。

如下图,产生问题时,问题的提醒信息就就会显示相关的 Issue 的 id 等信息。

Lint issue 示例

Category

Category 用于给 Issue 分类,系统已经提供了几个常用的分类,系统 Issue(即 Android Lint 自带的检查规则)也是使用这个 Category:

  • Lint
  • Correctness (子分类 Messages)
  • Security
  • Performance
  • Usability (子分类 Typography, Icons)
  • A11Y (Accessibility)
  • I18N (Internationalization,子分类 Rtl)

如果系统分类不能满足需求,也可以创建自定义的分类:

public class QMUICategory {
    public static final Category UI_SPECIFICATION = Category.create("UI Specification", 105);
}

使用如下:

public static final Issue ISSUE_JAVA_VECTOR_DRAWABLE =
        Issue.create("QMUIGetVectorDrawableWithWrongFunction",
                "Should use the corresponding method to get vector drawable.",
                "Using the normal method to get the vector drawable will cause a crash on Android versions below 4.0",
                QMUICategory.UI_SPECIFICATION, 2, Severity.ERROR,
                new Implementation(QMUIJavaVectorDrawableDetector.class, Scope.JAVA_FILE_SCOPE));

Registry

创建自定义 Lint 的最后一步是 “Lint-Registry”,如前面所述,build.gradle 中需要声明 Regisry 类,打包成 jar:

jar {
    manifest {
        attributes('Lint-Registry': 'com.qmuiteam.qmui.lint.QMUIIssueRegistry')
    }
}

而 registry 类中则是注册创建好的 Issue,以 QMUIIssueRegistry 为例:

public final class QMUIIssueRegistry extends IssueRegistry {
    @Override public List<Issue> getIssues() {
        return Arrays.asList(
                QMUIFWordDetector.ISSUE_F_WORD,
                QMUIJavaVectorDrawableDetector.ISSUE_JAVA_VECTOR_DRAWABLE,
                QMUIXmlVectorDrawableDetector.ISSUE_XML_VECTOR_DRAWABLE,
                QMUIImageSizeDetector.ISSUE_IMAGE_SIZE,
                QMUIImageScaleDetector.ISSUE_IMAGE_SCALE
        );
    }
}

QMUIIssueRegistry 继承与 IssueRegistryIssueRegistry 中注册了 Android Lint 自带的 Issue,而自定义的 Issue 则可以通过 getIssues 系列方法传入。

到这一步,这个用于自定义 Lint 的 Java 项目编写完毕了。

接入项目

按照上面的步骤,完成自定义 Lint 的编写后,编译 Gradle 可以得到对应的 jar 文件,那么 jar 应该如何接入项目,使得执行项目 Lint 时可以识别到这些自定义的规则呢?

Google 官方的方案是把 jar 文件放到 ~/.android/lint/,如果本地没有 lint 目录可以自行创建,这个使用方式较为简单,但也使得 Android Lint 作用于本地所有的项目,不大灵活。

因此我们推荐使用 Google adt-dev 论坛中被讨论推荐的方案,在主项目中新建一个 Module,打包为 aar,把 jar 文件放到该 aar 中,这样各个项目可以以 aar 的方式自行引入自定义 Lint,比较灵活,项目之间不会造成干扰。

Module 的 build.gradle 内容如下(以 QMUI Lint 为例):

apply plugin: 'com.android.library'

configurations {
    lintChecks
}

dependencies {
    lintChecks project(path: ':qmuilintrule', configuration: 'lintChecks')
}

task copyLintJar(type: Copy) {
    from(configurations.lintChecks) {
        rename { 'lint.jar' }
    }
    into 'build/intermediates/lint/'
}

project.afterEvaluate {
    def compileLintTask = project.tasks.find { it.name == 'compileLint' }
    compileLintTask.dependsOn(copyLintJar)
}

其中 qmuilintrule 是自定义 Lint 规则的 Module,这样这个需要进行 aar 打包的 Module 即可获取到 jar 文件,并放到 build/intermediates/lint/ 这个路径中。把 aar 发布到 Bintray 后,需要用到自定义 Lint 的地方只需要引入 aar 即可,例如:

compile 'com.qmuiteam:qmuilint:1.0.0'

另外需要注意,在编写自定义规则的 Lint 代码时,编写后重新构建 gradle,新代码也不一定生效,需要重启 Android Studio 才能确保新代码已经生效。

完整的示例代码可以参考 QMUI Androidqmuilintqmuilintrule module。

参考资料

]]>
https://kayosite.com/android-lint-custom-issue.html/feed 0 3562
Android Lint 实践 —— 简介及常见问题分析 https://kayosite.com/android-lint-intro-and-frequently-asked-questions.html https://kayosite.com/android-lint-intro-and-frequently-asked-questions.html#respond Wed, 11 Oct 2017 09:42:00 +0000 https://kayosite.com/?p=3554 概况

QMUI Android 刚更新了 1.0.4 版本,其中主要的特性是引入了 Android Lint,对项目代码进行优化。Android Lint 是 SDK Tools 16(ADT 16)开始引入的一个代码扫描工具,通过对代码进行静态分析,可以帮助开发者发现代码质量问题和提出一些改进建议。除了检查 Android 项目源码中潜在的错误,对于代码的正确性、安全性、性能、易用性、便利性和国际化方面也会作出检查。

而最终选择了 Android Lint 作为项目的代码检测工具,是因为它具有以下几个特性:

  • 已经被集成到 Android Studio,使用方便。
  • 能在编写代码时实时反馈出潜在的问题。
  • 可以自定义规则。Android Lint 本身包含大量已经封装好的接口,能提供丰富的代码信息,开发者可以基于这些信息进行自定义规则的编写。

开始使用

Android Lint 的工作过程比较简单,一个基础的 Lint 过程由 Lint Tool(检测工具),Source Files(项目源文件) 和 lint.xml(配置文件) 三个部分组成,Lint Tool 读取 Source Files,根据 lint.xml 配置的规则(issue)输出结果(如下图)。

Lint 工作流程
Lint 工作流程

如上面所描述,在 Android Studio 中,Android Lint 已经被集成,只需要点击菜单 —— Analyze —— Inspect Code 即可运行 Android Lint,在弹出的对话框中可以设置执行 Lint 的范围,可以选择整个项目,也可以只选择当前的子模块或者其他自定义的范围:

Specify Inspection Scope
选择执行 Lint 的范围

检查完毕后会弹出 Inspection 的控制台,并在其中列出详细的检查结果:

Lint 检查结果
Lint 执行结果

如上图所展示的,Android Lint 对检查的结果进行了分类,同一个规则(issue)下的问题会聚合,其中针对 Android 的规则类别会在分类前说明是 Android 相关的,主要是六类:

  • Accessibility 无障碍,例如 ImageView 缺少 contentDescription 描述,String 编码字符串等问题。
  • Correctness 正确性,例如 xml 中使用了不正确的属性值,Java 代码中直接使用了超过最低 SDK 要求的 API 等。
  • Internationalization 国际化,如字符缺少翻译等问题。
  • Performance 性能,例如在 onMeasureonDraw 中执行 new,内存泄露,产生了冗余的资源,xml 结构冗余等。
  • Security 安全性,例如没有使用 HTTPS 连接 Gradle,AndroidManifest 中的权限问题等。
  • Usability 易用性,例如缺少某些倍数的切图,重复图标等。

其他的结果条目则是针对 Java 语法的问题,另外每一个问题都有区分严重程度(severity),从高到底依次是:

  • Fatal
  • Error
  • Warning
  • Information
  • Ignore

其中 FatalError 都是指错误,但是 Fatal 类型的错误会直接中断 ADT 导出 APK,更为严重。另外如下图所示,在结果列表中点击一个条目,可以看到详细的源文件名和位置,以及命中的错误规则(issue)、解决方案或者屏蔽提示:

Lint 详情示例

上图的例子是在 ScrollView 的第一层子元素中设置了高度为 match_parent,Android Lint 会直接给出解决办法——使用 wrap_content 代替,大部分静态语法相关的问题 Android Lint 都可以直接给出解决办法。

除了直接在菜单中运行 Lint 外,大部分问题代码在编写时 Android Studio 就会给出提醒:

Lint 提醒示例

配置

对于执行 Lint 操作的相关配置,是定义在 gradle 文件的 lintOptions 中,可定义的选项及其默认值包括(翻译自 LintOptions - Android Plugin 2.3.0 DSL Reference):

android {
    lintOptions {
        // 设置为 true,则当 Lint 发现错误时停止 Gradle 构建
        abortOnError false
        // 设置为 true,则当有错误时会显示文件的全路径或绝对路径 (默认情况下为true)
        absolutePaths true
        // 仅检查指定的问题(根据 id 指定)
        check 'NewApi', 'InlinedApi'
        // 设置为 true 则检查所有的问题,包括默认不检查问题
        checkAllWarnings true
        // 设置为 true 后,release 构建都会以 Fatal 的设置来运行 Lint。
        // 如果构建时发现了致命(Fatal)的问题,会中止构建(具体由 abortOnError 控制)
        checkReleaseBuilds true
        // 不检查指定的问题(根据问题 id 指定)
        disable 'TypographyFractions','TypographyQuotes'
        // 检查指定的问题(根据 id 指定)
        enable 'RtlHardcoded','RtlCompat', 'RtlEnabled'
        // 在报告中是否返回对应的 Lint 说明
        explainIssues true
        // 写入报告的路径,默认为构建目录下的 lint-results.html
        htmlOutput file("lint-report.html")
        // 设置为 true 则会生成一个 HTML 格式的报告
        htmlReport true
        // 设置为 true 则只报告错误
        ignoreWarnings true
        // 重新指定 Lint 规则配置文件
        lintConfig file("default-lint.xml")
        // 设置为 true 则错误报告中不包括源代码的行号
        noLines true
        // 设置为 true 时 Lint 将不报告分析的进度
        quiet true
        // 覆盖 Lint 规则的严重程度,例如:
        severityOverrides ["MissingTranslation": LintOptions.SEVERITY_WARNING]
        // 设置为 true 则显示一个问题所在的所有地方,而不会截短列表
        showAll true
        // 配置写入输出结果的位置,格式可以是文件或 stdout
        textOutput 'stdout'
        // 设置为 true,则生成纯文本报告(默认为 false)
        textReport false
        // 设置为 true,则会把所有警告视为错误处理
        warningsAsErrors true
        // 写入检查报告的文件(不指定默认为 lint-results.xml)
        xmlOutput file("lint-report.xml")
        // 设置为 true 则会生成一个 XML 报告
        xmlReport false
        // 将指定问题(根据 id 指定)的严重级别(severity)设置为 Fatal
        fatal 'NewApi', 'InlineApi'
        // 将指定问题(根据 id 指定)的严重级别(severity)设置为 Error
        error 'Wakelock', 'TextViewEdits'
        // 将指定问题(根据 id 指定)的严重级别(severity)设置为 Warning
        warning 'ResourceAsColor'
        // 将指定问题(根据 id 指定)的严重级别(severity)设置为 ignore
        ignore 'TypographyQuotes'
    }
}

lint.xml 这个文件则是配置 Lint 需要禁用哪些规则(issue),以及自定义规则的严重程度(severity),lint.xml 文件是通过 issue 标签指定对一个规则的控制,在项目根目录中建立一个 lint.xml 文件后 Android Lint 会自动识别该文件,在执行检查时按照 lint.xml 的内容进行检查。如上面提到的那样,开发者也可以通过 lintOptions 中的 lintConfig 选项来指定配置文件。一个 lint.xml 示例如下:

<?xml version="1.0" encoding="UTF-8"?>
<lint>
    <!-- Disable the given check in this project -->
    <issue id="HardcodedText" severity="ignore"/>
    <issue id="SmallSp" severity="ignore"/>
    <issue id="IconMissingDensityFolder" severity="ignore"/>
    <issue id="RtlHardcoded" severity="ignore"/>
    <issue id="Deprecated" severity="warning">
        <ignore regexp="singleLine"/>
    </issue>
</lint>

issue 标签中使用 id 指定一个规则,severity="ignore" 则表明禁用这个规则。需要注意的是,某些规则可以通过 ignore 标签指定仅对某些属性禁用,例如上面的 Deprecated,表示检查是否有使用不推荐的属性和方法,而在 issue 标签中包裹一个 ignore 标签,在 ignore 标签的 regexp 属性中使用正则表达式指定了 singleLine,则表明对 singleLine 这个属性屏蔽检查。

另外开发者也可以使用 @SuppressLint(issue id) 标注针对某些代码忽略某些 Lint 检查,这个标注既可以加到成员变量之前,也可以加到方法声明和类声明之前,分别针对不同范围进行屏蔽。

常见问题

我们在使用 Android Lint 对项目进行检查后,整理了一些问题及解决方法,下面列举较为常见的场景:

ScrollView size validation

这也是上文提到过的一个情况,在 ScrollView 的第一层子元素中设置了高度为 match_parent,这是错误的写法,实际上在 measure 时这里必定会被当作 wrap_content 去处理,因此按照 Lint 的建议,直接改为 wrap_content 即可。

Handler reference leaks

Handler 引用的内存泄露问题,例如下面的例子:

protected static final int STOP = 0x10000;
protected static final int NEXT = 0x10001;

@BindView(R.id.rectProgressBar) QMUIProgressBar mRectProgressBar;
@BindView(R.id.circleProgressBar) QMUIProgressBar mCircleProgressBar;

int count;

private Handler myHandler = new Handler() {
    @Override
    public void handleMessage(Message msg) {
        super.handleMessage(msg);
        switch (msg.what) {
            case STOP:
                break;
            case NEXT:
                if (!Thread.currentThread().isInterrupted()) {
                    mRectProgressBar.setProgress(count);
                    mCircleProgressBar.setProgress(count);
                }
        }
    }
};

首先非静态的内部类或者匿名类会隐式的持有其外部类的引用,内部类使用了外部类的方法/成员变量也会导致其持有外部类引用,因此上面这种情况会导致 handler 持有了外部类,外部类同时持有 handler,handler 是异步的,当 handler 的消息发送出去后,外部类因 hanlder 的持有而无法销毁,最终导致内存泄露。

解决办法则是把该内部类改为 static,内部类中使用的外部类方法/成员变量改为弱引用,具体如下:

@BindView(R.id.rectProgressBar) QMUIProgressBar mRectProgressBar;
@BindView(R.id.circleProgressBar) QMUIProgressBar mCircleProgressBar;

int count;

private ProgressHandler myHandler = new ProgressHandler();

@Override
protected View onCreateView() {
    myHandler.setProgressBar(mRectProgressBar, mCircleProgressBar);
}

private static class ProgressHandler extends Handler {
    private WeakReference<QMUIProgressBar> weakRectProgressBar;
    private WeakReference<QMUIProgressBar> weakCircleProgressBar;

    public void setProgressBar(QMUIProgressBar rectProgressBar, QMUIProgressBar circleProgressBar) {
        weakRectProgressBar = new WeakReference<>(rectProgressBar);
        weakCircleProgressBar = new WeakReference<>(circleProgressBar);
    }

    @Override
    public void handleMessage(Message msg) {
        super.handleMessage(msg);
        switch (msg.what) {
            case STOP:
                break;
            case NEXT:
                if (!Thread.currentThread().isInterrupted()) {
                    if (weakRectProgressBar.get() != null && weakCircleProgressBar.get() != null) {
                        weakRectProgressBar.get().setProgress(msg.arg1);
                        weakCircleProgressBar.get().setProgress(msg.arg1);
                    }
                }
        }

    }
}

Memory allocations within drawing code

onMeasure、onDraw 都是被频繁调用的方法,因此 Lint 不建议在其中执行 new 操作,可以在 onCreateView 等非频繁调用的时机进行 new 操作,并用成员变量保存,再在 onMeasure 中使用成员变量。

‘private’ method declared ‘final’

private static final void addLinkMovementMethod(TextView t) {
    MovementMethod m = t.getMovementMethod();

    // ...
}

如上面的示例代码,会产生 ‘private’ method declared ‘final’ 的警告,因为私有方法是不会被 override 的,因此完全没有必要声明 final

参考资料:

使用 Lint 改进您的代码 | Android Studio
LintOptions - Android Plugin 2.3.0 DSL Reference
Android Lint Checks - Android Studio Project Site

]]>
https://kayosite.com/android-lint-intro-and-frequently-asked-questions.html/feed 0 3554
Web UI 解决方案 QMUI Web —— 探索与沉淀 https://kayosite.com/the-story-of-qmui-web.html https://kayosite.com/the-story-of-qmui-web.html#respond Thu, 07 Sep 2017 10:00:00 +0000 https://kayosite.com/?p=3673 经过长时间的打磨迭代,QMUI Web 作为腾讯广研 QMUI 团队的一个开源项目,正式发布到 Tencent Github。QMUI Web 是一个 Web UI 的解决方案,从零开始,由编码规范,到组件和工具方法的制作,再到工作流的整合,不断在迭代,也不断在优化,走过了不少的路。趁着发布的机会,我们正好回顾这一路的探索过程,分享其中的点滴,也希望能借此让大家更了解 QMUI Web。

背景

2014 年中,QMUI 团队支持的主要项目是 QQ 邮箱,Web 端的邮箱是个庞大的项目,但其并没有统一的 UI 基础库,多年的高速迭代使得项目的 UI 代码变得混乱,各个模块之间各自开发,除了在代码层面表现出混乱和不可控之外,表现层面也并没有很好地统一起来。因此,项目急需一套统一的团队编码规范以及一个 UI 基础库。
恰好,这个时候 Sass 等 CSS 预处理器已经发展成熟,自动化工作流的工作模式也日趋完善,因此,我们决定基于这些技术制作一套通用于不同项目的 Web UI 框架。框架的场景定位很明确:需要控制整体样式,并且可以适应频繁迭代打磨的大型项目。所以,这套即将诞生的 Web UI 框架的特性也很明确:需要方便地控制项目的整体样式,应对频繁的界面变动,并保持项目质量稳健。
此后经过三年的发展,QMUI Web 最终发展为包含编码规范、样式工具方法与样式管理、内置工作流,配套的 GUI 桌面 App,以及拥有完整文档的解决方案。

设计理念

在制作框架的过程中,我们把框架需要的特性进行整理和思考,形成了一套对于该框架的设计理念,在这些设计理念之中,最核心的关键为通用于多个项目高效迭代保持代码稳健,框架的设计也遵循这三个核心点,体现在框架上,具体就是:

  • 框架和组件需要剥离业务。作为 UI 框架,框架内整合的组件和样式必须有能力剥离业务,才能跨项目使用。
  • 能轻易控制整体样式。需要高效地迭代项目,样式的整体控制必不可少。
  • 保持代码稳健。

而具体到代码层面,则可以归纳为两个方面:

  • Class-name 命名规范。
  • 基础样式配置与半封装组件。

Class-name 命名规范

作为一个 Web UI 框架,编写代码主要是 CSS 与 HTML,而提到 CSS 与 HTML 的编写,首先要处理的是 Class-name 的命名,在过往的开发中,Class-name 的命名并没有固定的规范,开发人员各自进行开发,一个项目经过长时间的迭代后,经常会遇到如命名冲突,命名混乱等问题,这使得项目的迭代变得笨重,也不好维护。因此,我们需要一套具有如下特点的 Class-name 命名规范:

  • 命名有迹可循,容易编写。
  • 避免命名冲突,包括内部多人协作命名冲突,以及外部库引入时的被动污染。
  • 命名具有语义,能晰地描述整个页面,方便理解上下文。

因此,最终 QMUI Web 制定了一套以命名空间为核心的命名方式,这个命名方式主要由“命名空间”,“业务与组件的拆分”,“精确表达 View”三个部分构成。

命名空间

一个 QMUI Web Class-name 应该包含一个命名空间,也是 Class-name 的开头,如果是业务,则以业务内容为命名空间,如果是公共组件,则全局使用项目的名字(或缩写)为命名空间。如一个名为 Demo 的项目,项目缩写可以是 dm,那么该项目下的项目组件和公共类可以这样命名: dm_btn(按钮)、dm_icon(图标)、dm_ipt(输入框)、dm_toolbar(工具栏)。
逻辑模块命名以具体业务作为前缀,如简历(resume)功能里面的非公共组件部分,以 resume_ 作为前缀(resume_modresume_textresume_list),个人信息(profile)页面的非公共组件部分,则可以以 profile_ 作为前缀(profile_statgeprofile_stage_title)。
命名空间作为一种基础的隔离,把组件与业务,以及不同的业务之间的 Class-name 命名隔离开来,避免冲突,而后父子元素之间逐级展开编写,保证了项目内多人协助不易冲突,同时命名带有语义,也方便理解和阅读。

父子元素命名示例
扩展元素的命名示例

业务与组件的拆分

接着,QMUI Web 中把项目的代码划分为通用组件(跨项目的组件),项目全局组件(适用于某个具体项目),业务组件(适用于某个业务),以及业务逻辑代码,这样区分出4个颗粒度可以使得代码更容易被组织和复用,一个模块随着设计元素迭代,也可以在这4个颗粒度之间进行迭代,从而使得模块在迭代时会更加稳健。而 QMUI Web 框架中的组件应该只收纳通用组件,即跨项目组件。

精准表达 View

精准表达 View 是指在命名 DOM 节点时要明确这是一个怎样的 View,这里的 View 指的就是 UI 层面上这个元素表示的含义,常见的场景是,一个命名为 resume_head 的元素,在经历多次迭代后实际在代码中却充当了页脚,这样的命名在多人协作时很容易给后面的开发者造成困扰,而精准表达 View 则要求我们明确一个 UI 元素的含义,并在命名时准确地表达。

基础样式配置与半封装组件

前面的“Class-name 命名规范”主要是在规范层面上去实践 QMUI Web 的核心理念,而接着更多地就是在代码层面上去实践了,主要包括三点:

  • 半封装组件,即面向项目的组件。
  • 使用组合而不是继承。
  • 颗粒度的把控。

半封装组件即面向项目的组件

前文提到,QMUI Web 把组件划分为通用组件,项目全局组件,业务组件三种组件,而 QMUI Web 框架收纳的则是通用组件,也是跨项目的组件,但每个项目的 UI 表现并无关联,如何处理跨项目组件就成为了一个问题。为此,QMUI 在处理组件时采取的是“半封装”的处理方式,QMUI 框架封装的是代码,所谓半封装,即封装那些与项目具体 UI 表现没有必然联系的代码。例如按钮组件,QMUI Web 中只封装了文字居中对齐,鼠标手型,浏览器样式重置,低版本 IE 兼容性处理等代码,而常用的样式如边框、背景、字体表现等,都抽取成变量控制,这些组件的变量最终都汇集到一个配置表 Sass 文件中,配合全局的颜色变量、字体变量等变量,就可以做到跨项目抽取组件,每个新项目只需要关注具体 UI 表现而无需再处理各种常见的 UI 问题,同时方便地通过调整这些变量的值而快速修改整个项目的样式。

按钮组件样式代码

组合而不是继承

在处理组件时,继承的方式是指一个组件类承担复杂的功能,而组合的方式则是把组件类拆分成一个基类,以及多个子类,每个子类承担的功能不重复,对于我们的主场景——频繁迭代,保持稳健,显然组合会更加适合,这种方式避免了在频繁的迭代中需要不断修改组件类,每次迭代只需要修改对应的子类即可。

颗粒度

对于组件的抽取,时常要考虑颗粒度的划分,颗粒度本身就是一个比较开放性的问题,在这里与大家分享一些沉淀的经验:

  • 抽取组件以 UI 表现为区分,例如一个删除按钮,是以删除 icon + 删除文案作为内容的,但在表现上它就是一个带 icon 的文字按钮,因此就抽取出一个支持 icon 的文字按钮,而不用只局限于按“删除”这个业务来命名组件。
  • 抽取组件可以选择较大的颗粒度,也可以选择较小的颗粒度。颗粒度较大的组件实现复杂,能对应复杂的场景,但扩展性也会因此下降,而颗粒度较小的组件则实现简单,能轻松实现一个主场景,但又方便扩展,能灵活地应对变化。因此建议是像按钮、输入框、下拉菜单这类通常位于页面 DOM Tree 末端的元素可以抽取成尽量简单的组件,同时通过扩展的方式去处理各种场景差异。而其他复杂的组件则可以专注于一个业务,不必过多地考虑不同的场景,否则组件很容易变得难以维护。

以上便是 QMUI Web 具体的设计理念,通过命名规范、基础样式配置与半封装组件来保证多人协作时的高效率与可维护性,也使得一个 UI 框架能为不同的项目服务。

具体组成

作为一个框架,QMUI Web 主要提供了四种能力来提升 UI 开发的效率与质量,对应前文提到的框架设计理念,QMUI Web 提供的这些功能都是为了帮助开发者方便地控制项目整体样式,应对频繁变动,同时保持代码稳健。

基础配置与组件

前文提到,框架中会有一份配置表,是各种 Sass 的变量,这些变量控制了一个网页基本的字体样式,链接颜色,通用组件的样式配置等基础样式,在创建一个新项目时,应该先根据设计稿配置好这些信息,当这些信息配置完成,那么一个项目的基本样式就可以快速实现了。例如下图中这些配置属于 QMUI 通用配置,通过修改这些配置则可以快速修改项目的字体策略、正文字体大小,链接颜色等 UI 常用的 CSS 属性。

基础变量代码

内置工作流

QMUI 中包含一个基于 gulp 的内置工作流,用于快速解决大量重复劳动力的工作,从而提升效率。QMUI 的 gulp 中预先实现了监控 Sass 文件并自动编译和优化,雪碧图处理,模板 include 能力(可以传参和使用条件判断),浏览器自动刷新,图片压缩,文件清理,文件合并以及自动变更等能力。

工作流运行日志

Sass 增强支持

QMUI 中提供了大量基于 Sass 的 CSS 预处理的方法,包括 CSS Reset,一些常见的 CSS 类(例如清除浮动),计算长度值的简便方法(例如获取 padding 在某个方向的值,计算两个长度值的中间值),快速实现一些样式效果的工具方法(例如实现 border 三角形,适应多倍屏幕的 1px 边框等),这些都是用于提高样式开发的效率和质量。

扩展组件

扩展组件并不是由 QMUI Web 的主源码提供,而是由 Demo 提供,通常是因为这类组件结构较复杂,因此业务性无法很好地剥离,从而不能抽取成公共组件,因此这类组件就放在一个 Demo 页,以参考组件的形式帮助开发。

GUI

我们提供了一个用于管理 QMUI Web 项目的桌面 App,在代码层面它独立于 QMUI Web 的源码。它通过 GUI 界面处理 QMUI Web 的服务开启/关闭,并提供了编译提醒,出错提醒,进程关闭提醒等额外的功能,在处理多项目,多分支时能更方便地进行开发。

GUI 运行效果

优化和开源

在经历较长时间的迭代后,QMUI Web 也逐渐完善起来,此时我们也开始将 QMUI Web 进行开源。开源意味着 QMUI Web 会进入更加全面的环境中去打磨,在框架的非主体内容如代码规范、注释、文档上面也需要更费心思,考虑的点也需要更加周全。这对团队来说无疑是个很好的机会,可以有更多的渠道审视框架,吸收建议,持续进行优化。

在加入开源的大环境后,我们从 Github、社区论坛中都获取了不少建议,除了 bug 的反馈外,也指出了一些待完善的地方和提出一些优化的解决方案,从而使得 QMUI Web 注入了更多活力,因此我们也逐步进行了如“自动化测试用例”、“gulp 结构化”,“引入 SassDoc 自动化生成文档”,“编译 Sass 时引入增加更新”等优化,其中不少优化点我们也在项目的 Github Wiki 中进行了详细的分享,有兴趣的用户可以自行浏览。

总结与展望

至此,QMUI Web 发展为现在这套完整的方案,也终于开源到 Github Tencent 与大家分享,我们期望通过开源与大家进行更多的交流,也使得 QMUI Web 进入更加全面的环境中去打磨,形成对代码规范、注释、项目文档感谢公司与部门给我们提供了一个平台,可以在大型项目中经历迭代和沉淀。开源只是一个开始,我们后续仍会不断进行探索和优化,期待更好的 QMUI Web。

仓库地址:

https://github.com/Tencent/QMUI_Web

]]>
https://kayosite.com/the-story-of-qmui-web.html/feed 0 3673