Vue 进阶系列丨生命周期

35edc97e8d24d0f61c37f9abf7d9aac7.png

Vue 进阶系列教程将在本号持续发布,一起查漏补缺学个痛快!若您有遇到其它相关问题,非常欢迎在评论中留言讨论,达到帮助更多人的目的。若感本文对您有所帮助请点个赞吧!


2013年7月28日,尤雨溪第一次在 GItHub 上为 Vue.js 提交代码;2015年10月26日,Vue.js 1.0.0版本发布;2016年10月1日,Vue.js 2.0发布。

最早的 Vue.js 只做视图层,没有路由, 没有状态管理,也没有官方的构建工具,只有一个库,放到网页里就可以直接用了。

后来,Vue.js 慢慢开始加入了一些官方的辅助工具,比如路由(Router)、状态管理方案(Vuex)和构建工具(Vue-cli)等。此时,Vue.js 的定位是:The Progressive Framework。翻译成中文,就是渐进式框架。

Vue.js2.0 引入了很多特性,比如虚拟 DOM,支持 JSX 和 TypeScript,支持流式服务端渲染,提供了跨平台的能力等。Vue.js 在国内的用户有阿里巴巴、百度、腾讯、新浪、网易、滴滴出行、360、美团等等。

Vue 已是一名前端工程师必备的技能,现在就让我们开始深入学习 Vue.js 内部的核心技术原理吧!


什么是生命周期

百科全书定义的生命周期的概念是:生命周期就是指一个对象的生老病死。比如一个生物的出生、长大、成年、老年、死亡的全过程,相信这个大家应该都很好理解。

那么对于Vue.js来说,生命周期是什么呢?我们所创建的每一个 Vue.js实例,都要经历一系列的初始化过程,比如对状态设置数据监听(响应式基础)、编译模板(template)、将实例挂载到指定的 DOM 元素上、当数据改变时更新 DOM。

在这个过程中,Vue.js 会在响应的阶段执行指定的生命周期钩子函数,在每一个生命周期钩子函数里,我们可以去执行我们自己的代码,来确保功能的完成和整体性能优化。


生命周期图示

下图给出了 Vue.js 生命周期图示,分为 4 个阶段,初始化阶段、模板编译阶段、挂载阶段和卸载阶段。

87c83519f73888836e7e1a05347d4960.png

初始化阶段

从 new Vue() 到 created 之间的阶段叫做初始化阶段,在本阶段,主要是在Vue.js 的实例上初始化一些属性、事件和响应式数据,比如 props、methods、data、computed、watch、inject 和 provide 等。

模板编译阶段

从 created 到 beforeMounted 之间是模板编译阶段,主要是将模板编译成渲染函数,有关模板编译的详细内容,请移步Vue 进阶系列丨Patch 和模板编译

挂载阶段

从 beforeMounted 到 mounted 之间是挂载阶段,主要是将实例挂载到指定的 DOM 元素上,在挂载的过程中,会开启状态监听,当状态发生变化时,虚拟 DOM 会收到监听器发送的通知,重新渲染 DOM,已达到响应式。在渲染视图之前会触发 beforeUpdate 函数,渲染视图之后,触发 updated 函数,这个状态会一致持续到 Vue.js 实例组件被销毁。

卸载阶段

从 beforeDestory 到 destoryed 之间是卸载阶段,当我们调用Vue.$destory 方法后,Vue.js 会进入卸载阶段。Vue.js 会将自身从父组件删除、清空所有依赖追踪和所有事件监听器。


new Vue() 被调用时发生了什么

当调用 new Vue() 时,会先进行一些初始化阶段,然后进入模板编译阶段,最后是挂载阶段。那么刚刚调用时,Vue 内部都做了哪些操作呢?我们引用Vue.js 源码来看一看。

function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(this instanceof Vue)
  ) {
    warn('Vue is a constructor and should be called with the `new` keyword')
  }
  this._init(options)
}

从源码可以看出,首先进行安全检查,在非生产环境中,如果没有用new来调用 Vue,会在控制台抛出错误警告。

然后调用 this._init(options) 来执行生命周期的初始化阶段。下面我们来看下

this._init 函数的源码。

Vue.prototype._init = function (options?: Object) {
    // merge options
    vm.$options = mergeOptions(
      resolveConstructorOptions(vm.constructor),
      options || {},
      vm
    )


    // expose real self
    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)
    }
  }

在 this._init() 方法内部,首先我们将用户传递的 options 选项和当前构造函数的 options 属性及其父级实例构造函数的 options 属性,合并成一个新的options 并赋值给 $options 属性。

resolveConstructorOptions(vm.constructor) 是用来获取当前实例中构造函数的 options 属性和其父级构造函数的 options。

然后是通过 initLifecycle(vm)、initEvents(vm)、initRender(vm)、initInjections(vm)、initState(vm)、initProvide(vm) 来进行相应的初始化操作。并且在初始化过程中,通过 callHook 函数触发相应的生命周期钩子函数。


初始化实例属性

接初始化实例属性是 Vue 整个生命周期的第一步,既包含 Vue 内部自己需要用到的属性,比如 vm._watcher,也有提供给外部使用的属性,比如vm.$parent。

以下划线“_”开头的属性是提供给 Vue 内部使用的,也就是源码内部逻辑使用的,以“$”开头的属性是提供给外部使用的,也就是暴漏给外部用户使用的属性。

实例化属性是在 initLifecycle 函数中实现的,我们先看一下源码部分:

export function initLifecycle (vm: Component) {
  const options = vm.$options
  
  // locate first non-abstract parent
  let parent = options.parent
  if (parent && !options.abstract) {
    while (parent.$options.abstract && parent.$parent) {
      parent = parent.$parent
    }
    parent.$children.push(vm)
  }


  vm.$parent = parent
  vm.$root = parent ? parent.$root : vm


  vm.$children = []
  vm.$refs = {}


  vm._watcher = null
  vm._inactive = null
  vm._directInactive = false
  vm._isMounted = false
  vm._isDestroyed = false
  vm._isBeingDestroyed = false
}

实现方法很简单,就是在 Vue.js 的实例上设置一些属性并且提供相应的默认值。

但是 vm.$parent 属性并不是简单的赋值操作,需要找到第一个非抽象类型的父级。通过 while 循环,直到遇到第一个非抽象类的父级时,才将其赋值给 vm.$parent。

vm.$children 保存的是当前组件的子组件,是由子组件自己添加的,当子组件找到 vm.$parent 后,通过 parent.$children.push(vm),将自己添加到父级组件的 vm.$children 属性中。

还有一个就是 vm.$root,表示的是当前组件树的根组件,循环自己的父组件,知道父组件的 $root 属性还是父组件本身的时候,才会将其赋值到当前组件的 $root 中。如果当前组件没有父组件,那么自己就是根组件,就将自身设置为 $root 属性。


初始化事件

初始化事件,是指初始化父组通过 v-on(也就是@),向子组件的事件系统中添加事件。当我们通过父子组件传参时,会遇到父组件通过 v-on 监听子组件的事件触发,子组件可以通过 this.$emit 来触发事件。

如果 v-on 写在了组件标签上,那么这个事件就会注册到自组件的事件系统中,如果是写在了 HTML 标签上,会注册到浏览器事件中。

子组件在初始化的时候,可能会接受到父组件向子组件注册的事件,也可能接收到自身在 HTML 标签上注册的事件,但是只有在渲染的时候才会根据虚拟 DOM 的对比结果,来确定到底是注册事件还是解绑事件。

所以初始化事件指的是父组件向子组件注册的事件。这段实现,是在initEvents 函数中实现的,我们先看一下源码部分。

export function initEvents (vm: Component) {
  vm._events = Object.create(null)
  vm._hasHookEvent = false
  // init parent attached events
  const listeners = vm.$options._parentListeners
  if (listeners) {
    updateComponentListeners(vm, listeners)
  }
}

vm._events 是用来存储事件的,所有通过 vm.$on 注册的事件都会保存在这里。

vm.$options._parentListeners 用来存储父组件向自己注册的事件。

下面我们来看 updateComponentListeners 内部实现。

export function updateComponentListeners (
  vm: Component,
  listeners: Object,
  oldListeners: ?Object
) {
  target = vm
  updateListeners(listeners, oldListeners || {}, add, remove, createOnceHandler, vm)
  target = undefined
}

updateComponentListeners 的逻辑很简单,就是循环vm.$options._parentListeners,判断新的事件列表和旧的事件列表,通过add 和 remove 方法,将事件注册到 this._events 中,还是从 this._events中移除事件。

下面是 add 和 remove 方法源码部分

function add (event, fn) {
  target.$on(event, fn)
}


function remove (event, fn) {
  target.$off(event, fn)
}

updateListeners 函数就是来对比新的事件列表和旧的事件列表,并且进行新增和删除操作的。

源码部分如下:

export function updateListeners (
  on: Object,
  oldOn: Object,
  add: Function,
  remove: Function,
  createOnceHandler: Function,
  vm: Component
) {
  let name, def, cur, old, event
  for (name in on) {
    cur = on[name]
    old = oldOn[name]
    event = normalizeEvent(name)
    if (isUndef(cur)) {
      process.env.NODE_ENV !== 'production' && warn(
        `Invalid handler for event "${event.name}": got ` + String(cur),
        vm
      )
    } else if (isUndef(old)) {
      if (isUndef(cur.fns)) {
        cur = on[name] = createFnInvoker(cur, vm)
      }
      add(event.name, cur, event.capture, event.passive, event.params)
    } else if (cur !== old) {
      old.fns = cur
      on[name] = old
    }
  }
  for (name in oldOn) {
    if (isUndef(on[name])) {
      event = normalizeEvent(name)
      remove(event.name, oldOn[name], event.capture)
    }
  }
}

初始化 inject

inject 和 provide 是成对出现的,目的是允许祖先组件向子孙组件注入依赖,也可以说是传递数据。我们平时使用的 vm.$parent 仅仅保证我们可以获取父组件的相关内容,但是当我们想要拿到祖先组件的内容时,这种方法就有点麻烦了。

provide 选项是祖先组件注入依赖的过程,像是祖先组件提前声明好了一些内容,这些内容是可以向子孙组件暴露的,可以是一个对象或者一个返回对象的函数。对象也可以使用 Symbol 作为 key。

inject 选项是子孙组件用来拿到注入内容的,通过 inject 可以拿到祖先组件通过 provide 选项暴露的那些数据,当然你可以选择性拿,不用全拿。inject可以是一个数组,也可以是一个对象。对象的 key 是你本地的绑定名,value是一个 key(字符串或Symbol),也就是 provide 开始暴露的 key,用来在已注入的内容里面搜索。

如果是一个对象的话,具备两个属性:

  • name:一个key(字符串或Symbol),用来在已注入的内容里面搜索

  • default:在已注入的内容里面搜索不到之后的默认值

我们通过下面的示例代码,来加深对 provide 和 inject 的熟悉

// 子孙组件拿到注入的值
var Provider = {
  provide:{
    name:'zhangsan'
  }
}
var Injecter = {
  inject:['name'],
  created(){
    console.log(this.name) // zhangsan
  }
}
----------------------------------------------------------------
// 子孙组件拿到注入的值,重新命名且设置默认值
var Provider = {
  provide:{
    name:'zhangsan'
  }
}
var Injecter = {
  inject:{
    name2:{
      from:'name',
      default:'没拿到name'
    }
  },
  created(){
    console.log(this.name2) // zhangsan
  }
}
----------------------------------------------------------------
// 也可以在data/props里拿到注入的值,因为初始化状态阶段是在初始化inject之后进行的,
// 所以在data/props里可以拿到inject的值
var Provider = {
  provide:{
    name:'zhangsan'
  }
}
var Injecter = {
  inject:['name'],
  props:{
    name2:{
      return this.name // 使用inject作为props的默认值
    }
  }
}
var Injecter2 = {
  inject:['name'],
  data(){
    return {
      name2:this.name // 使用inject作为name2的数据入口
    }
  }
}

inject的内部原理

知道了怎么使用了之后,现在来看一下他的内部实现是怎么样的。我们可以看到初始化 inject 和初始化 provide 并不是一起进行的,而是在 data/props 之前初始化 inject,之后初始化 provide。目的就是为了确保 data/props 里可以访问到 inject 里的内容。

inject 的实现逻辑也很简单,通过使用阶段可以得知,inject 其实就是拿到祖先组件 provide 的值。我们只需要一级一级向上查找父级的 provide 里有没有我们想要的值就可以了,如果没找到,继续向上级查找,直到找到了根组件,还没找到的话,就将默认值保存到当前实例中(this)上,找到了,就将找到的内容保存到实例(this)中,这样就可以直接通过 this 访问 inject 导入的内容了。

相应源码如下:

function initInjections (vm) {
    var result = resolveInject(vm.$options.inject, vm);
    if (result) {
      Object.keys(result).forEach(function (key) {
        /* istanbul ignore else */
        {
          defineReactive$$1(vm, key, result[key], function () {
            warn(
              "Avoid mutating an injected value directly since the changes will be " +
              "overwritten whenever the provided component re-renders. " +
              "injection being mutated: \"" + key + "\"",
              vm
            );
          });
        }
      });
    }
  }

其中,resolveInject 函数的作用是通过用户配置的 inject,自底向上搜索可用的注入内容,找到后返回结果内容。然后循环结果内容,依次调用 defineReactive 函数,将他们设置到 Vue.js 实例上。


初始化状态

在我们实际开发项目的时候,经常使用一些状态,比如 props、methods、data、computed 和 watch,这些状态在 Vue 内部是怎么初始化的呢?我们先看一下源码部分

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)
  }
}

vm._watchers 是用来存储当前实例的监听器的,Vue.js 每个状态变化时,通知到的监听器就在这个里面,当变化时,循环 vm._watchers,找到对应的监听器,执行监听器,通知虚拟 DOM,虚拟 DOM 进行 diff 算法,然后重新渲染 DOM,更新页面。

而且只有当我们使用了这个状态,这个状态才会初始化。比如我们只使用了data,那么只会初始化 data 即可。

这里需要注意一点的是,watch 是最后初始化的,这样可以确保我们可以在watch 中既可以监听 props 的变化,也可以监听 data 的变化。


初始化 props

当进行父子组件传参时,子组件通过 props,接收父组件传递的数据,并且可以选择性接收。Vue.js 内部会将子组件接收的 props 属性筛选出来,然后添加到子组件的上下文中。

1. 规格化 props

我们通过 props 接收数据时,既可以通过数组形式接收,也可以通过对象形式接收,子组件被实例化时,会先对 props 进行规格化处理,规格化处理后的 props 为对象的格式,目的是统一格式,方便后期处理。规格化部分的源码部分如下。

function normalizeProps (options: Object, vm: ?Component) {
  const props = options.props
  // 先判断有没有props
  if (!props) return
  // 声明res,保存规格化之后的结果
  const res = {}
  let i, val, name
  // 判断是否是数组
  if (Array.isArray(props)) {
    i = props.length
    // 循环数组
    while (i--) {
      val = props[i]
      // props的名称是String,使用camelize函数将props名称驼峰化,就是将a-b转为aB
      if (typeof val === 'string') {
        name = camelize(val)
        res[name] = { type: null }
      } else if (process.env.NODE_ENV !== 'production') {
        // 否则,在非生产环境打印警告
        warn('props must be strings when using array syntax.')
      }
    }
    // 不是数组,判断是不是对象
  } else if (isPlainObject(props)) {
    // 循环对象
    for (const key in props) {
      val = props[key]
      name = camelize(key)
      // 设置一个key为名的属性,值为{type:val}的属性
      res[name] = isPlainObject(val)
        ? val
        : { type: val }
    }
  } else if (process.env.NODE_ENV !== 'production') {
    // 不是数组,也不是对象,打印警告
    warn(
      `Invalid value for option "props": expected an Array or an Object, ` +
      `but got ${toRawType(props)}.`,
      vm
    )
  }
  // 将规格化后的数据重新保存
  options.props = res
}

2. 初始化 props

初始化的步骤是通过规格化后的 props,从他的父组件身上传进来的数据里筛选出需要的数据,保存在 vm._props 中,然后在 vm 上设置一个代理,让我们可以通过 vm.x 访问到 vm._props.x,也就是可以通过 this 访问到 props。相关代码如下:

function initProps (vm: Component, propsOptions: Object) {
  // 保存父组件传入或者用户通过propsData传入的props
  const propsData = vm.$options.propsData || {}
  // 指向vm._props的指针
  const props = vm._props = {}
  // 指向vm.$options._propKeys的指针
  const keys = vm.$options._propKeys = []
  // 判断是不是根组件
  const isRoot = !vm.$parent
  if (!isRoot) {
    // 不是根组件,不需要将props数据转换成响应式数据
    toggleObserving(false)
  }
  // 循环propsOptions
  for (const key in propsOptions) {
      // 将key添加到keys中
      keys.push(key)
      // 调用validateProp函数将得到的props数据,通过defineReactive函数设置到vm._props上
      const value = validateProp(key, propsOptions, propsData, vm)
      defineReactive(props, key, value)
    }
    // 循环vm,为vm._props设置代理
    if (!(key in vm)) {
      proxy(vm, `_props`, key)
    }
  }
  toggleObserving(true)
}

初始化 methods

初始化 methods 很简单,就是循环模板选项中的 methods 对象,将每个属性都挂载到 vm 上。相关代码如下:

function initMethods (vm: Component, methods: Object) {
   // 用来判断methods中的方法是否和props中的重复了
  const props = vm.$options.props 
  // 循环methods
  for (const key in methods) {
    // 检查方法是否合法
    if (process.env.NODE_ENV !== 'production') {
      // 检查方法是不是只有key,没有value
      if (typeof methods[key] !== 'function') {
        warn(
          `Method "${key}" has type "${typeof methods[key]}" in the component definition. ` +
          `Did you reference the function correctly?`,
          vm
        )
      }
      // 检查方法是否已经存在props里了
      if (props && hasOwn(props, key)) {
        warn(
          `Method "${key}" has already been defined as a prop.`,
          vm
        )
      }
      // 检查方法是否已经存在了,或者是否是以_或者$开头的
      if ((key in vm) && isReserved(key)) {
        warn(
          `Method "${key}" conflicts with an existing Vue instance method. ` +
          `Avoid defining component methods that start with _ or $.`
        )
      }
    }
   // 将方法挂载到vm上
    vm[key] = typeof methods[key] !== 'function' ? noop : bind(methods[key], vm)
  }
}

初始化 data

初始化 data,data 是保存在 vm._data 属性中的,通过在 vm 上设置一个代理,使得通过 vm.x 可以访问到 vm._data 中的 x 属性,最后通过一个叫做Observe 的函数将 data 转换成响应式数据,就是添加监听器。于是,data就完成了响应式。相关代码如下

function initData (vm: Component) {
  // 拿到模板中的data对象
  let data = vm.$options.data
  // 判断data的类型,如果是函数就先执行函数,并将返回值赋值给data和vm._data
  // getData也是执行函数,将返回值赋值给data和vm._data。
  // 只不过里面有try...catch 捕获data函数有可能出现的错误
  data = vm._data = typeof data === 'function'
    ? getData(data, vm) 
    : data || {}
  // 判断是否是Object类型,如果不是且在非生产环境就发出警告
  if (!isPlainObject(data)) {
    data = {}
    process.env.NODE_ENV !== 'production' && warn(
      'data functions should return an object:\n' +
      'https://vuejs.org/v2/guide/components.html#data-Must-Be-a-Function',
      vm
    )
  }
  // 将data代理到实例上
  const keys = Object.keys(data)
  const props = vm.$options.props
  const methods = vm.$options.methods
  let i = keys.length
  // 循环data
  while (i--) {
    const key = keys[i]
    // 判断是否是恒产环境
    if (process.env.NODE_ENV !== 'production') {
      // 不是生产环境,判断当前循环的key是否存在于methods中,如果存在,就说明重复了,发出警告
      if (methods && hasOwn(methods, key)) {
        warn(
          `Method "${key}" has already been defined as a data property.`,
          vm
        )
      }
    }
    // 不是生产环境,判断当前循环的key是否存在于props中,如果存在,就说明重复了,发出警告
    if (props && hasOwn(props, key)) {
      process.env.NODE_ENV !== 'production' && warn(
        `The data property "${key}" is already declared as a prop. ` +
        `Use prop default value instead.`,
        vm
      )
    } else if (!isReserved(key)) {
      // 调用proxy函数实现代理功能
      proxy(vm, `_data`, key)
    }
  }
  // 将数据转换为响应式
  observe(data, true /* asRootData */)
}

初始化 computed

计算属性在我们平时项目中也是经常出现的一个状态,他和 watch 的不同之处就在于计算属性具有缓存特性,只有计算属性依赖的值发生变化时,计算属性才会重新计算,否则会反复读取计算属性,而计算属性函数不会反复执行。相关代码如下:

function initComputed (vm: Component, computed: Object) {
  // vm._computedWatchers用来保存所有的计算属性的监听器watcher实例
  const watchers = vm._computedWatchers = Object.create(null)


  // 循环computed对象
  for (const key in computed) {
    // 保存用户设置的计算属性定义
    const userDef = computed[key]
    // 通过userDef获取getter函数
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    // 如果是非生产环境且getter为null,则发出警告
    if (process.env.NODE_ENV !== 'production' && getter == null) {
      warn(
        `Getter is missing for computed property "${key}".`,
        vm
      )
    }
    // 为计算属性创建监听器watcher实例
    watchers[key] = new Watcher(
      vm,
      getter || noop,
      noop,
      computedWatcherOptions
    )
    // 判断当前循环到的属性是否已经存在于vm中
    if (!(key in vm)) {
      // 不存在,使用defineComputed函数在vm上设置个计算属性
      defineComputed(vm, key, userDef)
    } else if (process.env.NODE_ENV !== 'production') {
      // 已经存在,在非生产环境下发出警告
      if (key in vm.$data) {
        warn(`The computed property "${key}" is already defined in data.`, vm)
      } else if (vm.$options.props && key in vm.$options.props) {
        warn(`The computed property "${key}" is already defined as a prop.`, vm)
      } else if (vm.$options.methods && key in vm.$options.methods) {
        warn(`The computed property "${key}" is already defined as a method.`, vm)
      }
    }
  }
}

初始化 watch

初始化的最后一步是初始化 watch,目的就是可以在 watch 中既可以监听data,也可以监听 props,当然也可以监听 computed 的变化。实现方式很简单,就是循环模板中的 watch 选项,然后依次使用 vm.$watch方法,观察被监听状态的变化。相关源码如下:

function initWatch (vm: Component, watch: Object) {
  // 循环模板中的watch选项
  for (const key in watch) {
    // 通过key得到watch的值,赋值给handler
    const handler = watch[key]
    // 如果watch是一个数组形式的,就遍历数组,依次调用createWatcher函数创建对状态的监听器watcher
    if (Array.isArray(handler)) {
      for (let i = 0; i < handler.length; i++) {
        createWatcher(vm, key, handler[i])
      }
    } else {
      // 不是数组,就直接调用createWatcher函数创建对状态的监听器watcher
      createWatcher(vm, key, handler)
    }
  }
}

初始化 provide

生命周期初始化阶段的最后是初始化 provide,初始化 provide 不像 inject那样麻烦,我们只需要将 provide 中的内容保存在一个地方,然后 inject 去这个地方查找就好了。相关代码如下:

export function initProvide (vm: Component) {
  // 拿到模板中的provide
  const provide = vm.$options.provide
  if (provide) {
    // 判断是不是函数,如果是就先执行,将执行结果赋值给vm._provided,否则直接将变量赋值给vm._provided
    vm._provided = typeof provide === 'function'
      ? provide.call(vm)
      : provide
  }
}

Vue 进阶系列教程将在本号持续发布,一起查漏补缺学个痛快!若您有遇到其它相关问题,非常欢迎在评论中留言讨论,达到帮助更多人的目的。若感本文对您有所帮助请点个赞吧!

37910bf57d0bdec1597bcb53885bd996.png

叶阳辉

HFun 前端攻城狮

往期精彩:

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值