深入理解 Vue3 组件的实现原理:props 与组件的被动更新

什么是组件的被动更新?

由 props 引起的组件更新为组件的被动更新。组件自己内部状态引起的更新为自更新,props 本质是父组件的数据,因此父组件会先进行自更新。

从源码层面,认识组件的 props

在虚拟 DOM 层面,组件的 props 与普通 HTML 标签的属性差别不大。假设有如下模板:

html 复制代码
<MyComponent title="A Big Title" :other="val" />

这段模板对应的虚拟 DOM 是:

js 复制代码
const vnode = {
  type: MyComponent,
  props: {
    title: 'A big Title',
    other: this.val
  }
}

可以看到,模板与虚拟 DOM 几乎是"同构"的。另外,在编写组件时,我们需要显式地指定组件会接收哪些 props 数据,如下面的代码所示:

js 复制代码
const MyComponent = {
  name: 'MyComponent',
  // 组件接收名为 title 的 props,并且该 props 的类型为 String
  props: {
    title: String
  },
  render() {
    return {
      type: 'div',
      children: `title is: ${this.title}` // 访问 props 数据
    }
  }
}

在初始化组件实例的时候,会从虚拟 DOM 的 type 属性中获取组件的 props 。

ts 复制代码
// packages/runtime-core/src/renderer.ts
const mountComponent: MountComponentFn = (
  initialVNode,
  container,
  anchor,
  parentComponent,
  parentSuspense,
  isSVG,
  optimized
) => {
  // 2.x compat may pre-create the component instance before actually
  // mounting
  const compatMountInstance =
    __COMPAT__ && initialVNode.isCompatRoot && initialVNode.component
  // 获取组件实例
  const instance: ComponentInternalInstance =
    compatMountInstance ||
    (initialVNode.component = createComponentInstance(
      initialVNode,
      parentComponent,
      parentSuspense
    ))
  // 省略其他代码
}

本文中的源码均摘自 Vue.js 3.2.45

createComponentInstance 函数用于创建组件实例

ts 复制代码
// packages/runtime-core/src/component.ts

let uid = 0

export function createComponentInstance(
  vnode: VNode,
  parent: ComponentInternalInstance | null,
  suspense: SuspenseBoundary | null
) {
  const type = vnode.type as ConcreteComponent
  // inherit parent app context - or - if root, adopt from root vnode
  const appContext =
    (parent ? parent.appContext : vnode.appContext) || emptyAppContext

  const instance: ComponentInternalInstance = {
    uid: uid++,
    // 省略其他代码
    propsOptions: normalizePropsOptions(type, appContext),
  }
  // 返回组件实例
  return instance
}

其中,normalizePropsOptions 函数用于标准化组件的 props

ts 复制代码
// packages/runtime-core/src/componentProps.ts

export function normalizePropsOptions(
  comp: ConcreteComponent,
  appContext: AppContext,
  asMixin = false
): NormalizedPropsOptions {
  const cache = appContext.propsCache
  const cached = cache.get(comp)
  if (cached) {
    return cached
  }

  const raw = comp.props
  const normalized: NormalizedPropsOptions[0] = {}
  const needCastKeys: NormalizedPropsOptions[1] = []

  // apply mixin/extends props
  let hasExtends = false
  if (__FEATURE_OPTIONS_API__ && !isFunction(comp)) {
    const extendProps = (raw: ComponentOptions) => {
      if (__COMPAT__ && isFunction(raw)) {
        raw = raw.options
      }
      hasExtends = true
      const [props, keys] = normalizePropsOptions(raw, appContext, true)
      extend(normalized, props)
      if (keys) needCastKeys.push(...keys)
    }
    if (!asMixin && appContext.mixins.length) {
      appContext.mixins.forEach(extendProps)
    }
    if (comp.extends) {
      extendProps(comp.extends)
    }
    if (comp.mixins) {
      comp.mixins.forEach(extendProps)
    }
  }

  if (!raw && !hasExtends) {
    if (isObject(comp)) {
      cache.set(comp, EMPTY_ARR as any)
    }
    return EMPTY_ARR as any
  }

  if (isArray(raw)) {
    for (let i = 0; i < raw.length; i++) {
      if (__DEV__ && !isString(raw[i])) {
        warn(`props must be strings when using array syntax.`, raw[i])
      }
      const normalizedKey = camelize(raw[i])
      if (validatePropName(normalizedKey)) {
        normalized[normalizedKey] = EMPTY_OBJ
      }
    }
  } else if (raw) {
    if (__DEV__ && !isObject(raw)) {
      warn(`invalid props options`, raw)
    }
    for (const key in raw) {
      const normalizedKey = camelize(key)
      if (validatePropName(normalizedKey)) {
        const opt = raw[key]
        const prop: NormalizedProp = (normalized[normalizedKey] =
          isArray(opt) || isFunction(opt) ? { type: opt } : { ...opt })
        if (prop) {
          const booleanIndex = getTypeIndex(Boolean, prop.type)
          const stringIndex = getTypeIndex(String, prop.type)
          prop[BooleanFlags.shouldCast] = booleanIndex > -1
          prop[BooleanFlags.shouldCastTrue] =
            stringIndex < 0 || booleanIndex < stringIndex
          // if the prop needs boolean casting or default value
          if (booleanIndex > -1 || hasOwn(prop, 'default')) {
            needCastKeys.push(normalizedKey)
          }
        }
      }
    }
  }

  const res: NormalizedPropsOptions = [normalized, needCastKeys]
  if (isObject(comp)) {
    cache.set(comp, res)
  }
  return res
}

为了提升性能,在正式标准化组件 props 前,会从应用程序上下文(appContext)中获取 props 缓存,并判断是否已经缓存了该组件,如果有,则直接缓存已标准化的 props

ts 复制代码
const cache = appContext.propsCache
const cached = cache.get(comp)
if (cached) {
  return cached
}

如果缓存中不存在当前组件(comp),则从当前组件(comp)中取得原始的 props

ts 复制代码
const raw = comp.props
const normalized: NormalizedPropsOptions[0] = {}
const needCastKeys: NormalizedPropsOptions[1] = []

标准化的 props 是一个元组类型(NormalizedPropsOptions)

ts 复制代码
export interface PropOptions<T = any, D = T> {
  type?: PropType<T> | true | null
  required?: boolean
  default?: D | DefaultFactory<D> | null | undefined | object
  validator?(value: unknown): boolean
}

const enum BooleanFlags {
  shouldCast,
  shouldCastTrue
}

type NormalizedProp =
  | null
  | (PropOptions & {
      [BooleanFlags.shouldCast]?: boolean
      [BooleanFlags.shouldCastTrue]?: boolean
    })

export type NormalizedProps = Record<string, NormalizedProp>
export type NormalizedPropsOptions = [NormalizedProps, string[]] | []

元组类型(NormalizedPropsOptions)第一个元素是一个对象,第二个元素是一个字符串数组。Vue3 会将组件传入的 props 标准化为一个数组,该数组中第一个元素是对象,第二个元素的字符串数组。

TypeScript 中的元组类型用于表示具有固定数量和特定类型的有序元素的数组。元组类型可以确保数组中的每个元素都具有指定的类型,并且元素的顺序与元组类型声明的顺序一致。这样可以在编译阶段捕获潜在的类型错误。

递归地调用 normalizePropsOptions 函数,从 mixinsextends 中标准化 props 。

ts 复制代码
// apply mixin/extends props
let hasExtends = false
if (__FEATURE_OPTIONS_API__ && !isFunction(comp)) {
  const extendProps = (raw: ComponentOptions) => {
    if (__COMPAT__ && isFunction(raw)) {
      raw = raw.options
    }
    hasExtends = true
    const [props, keys] = normalizePropsOptions(raw, appContext, true)
    extend(normalized, props)
    if (keys) needCastKeys.push(...keys)
  }
  if (!asMixin && appContext.mixins.length) {
    appContext.mixins.forEach(extendProps)
  }
  if (comp.extends) {
    extendProps(comp.extends)
  }
  if (comp.mixins) {
    comp.mixins.forEach(extendProps)
  }
}
ts 复制代码
// packages/shared/src/index.ts

export const extend = Object.assign
export const isFunction = (val: unknown): val is Function =>
  typeof val === 'function'

__FEATURE_OPTIONS_API__,rollup 的环境变量,是否开启了选项式风格的 API

如果组件本身没有 props 同时 mixin 、extends 也没有 props 则该组件没有 props ,则只需返回空对象。

ts 复制代码
if (!raw && !hasExtends) {
  if (isObject(comp)) {
    cache.set(comp, EMPTY_ARR as any)
  }
  return EMPTY_ARR as any
}
ts 复制代码
export const EMPTY_ARR = __DEV__ ? Object.freeze([]) : []

Object.freeze() 是 JavaScript 中一个用于冻结对象的方法。当一个对象被冻结后,无法再添加、修改或删除该对象的属性和方法,使其变为不可变的。通过使用 Object.freeze() 方法,可以确保对象的属性不被意外修改,提高代码的安全性和可靠性。

可以使用数组的方式声明 props ,也可以使用对象的方式声明 props

html 复制代码
<script setup>
const props = defineProps(['foo'])

console.log(props.foo)
</script>
js 复制代码
// 使用 <script setup>
defineProps({
  title: String,
  likes: Number
})
ts 复制代码
if (isArray(raw)) {
  // 用户以数组的方式声明 props
  for (let i = 0; i < raw.length; i++) {
    if (__DEV__ && !isString(raw[i])) {
      warn(`props must be strings when using array syntax.`, raw[i])
    }
    const normalizedKey = camelize(raw[i])
    if (validatePropName(normalizedKey)) {
      normalized[normalizedKey] = EMPTY_OBJ
    }
  }
} else if (raw) {
  if (__DEV__ && !isObject(raw)) {
    warn(`invalid props options`, raw)
  }
  // 用户以对象的方式声明 props
  for (const key in raw) {
    const normalizedKey = camelize(key)
    if (validatePropName(normalizedKey)) {
      const opt = raw[key]
      const prop: NormalizedProp = (normalized[normalizedKey] =
        isArray(opt) || isFunction(opt) ? { type: opt } : { ...opt })
      if (prop) {
        const booleanIndex = getTypeIndex(Boolean, prop.type)
        const stringIndex = getTypeIndex(String, prop.type)
        prop[BooleanFlags.shouldCast] = booleanIndex > -1
        prop[BooleanFlags.shouldCastTrue] =
          stringIndex < 0 || booleanIndex < stringIndex
        // if the prop needs boolean casting or default value
        if (booleanIndex > -1 || hasOwn(prop, 'default')) {
          needCastKeys.push(normalizedKey)
        }
      }
    }
  }
}

Vue 会将用户传入的 props 中的 key 转换为驼峰的形式,比如,lang-content 会被转换为 langContent

ts 复制代码
const camelizeRE = /-(\w)/g

const cacheStringFunction = <T extends (str: string) => string>(fn: T): T => {
  const cache: Record<string, string> = Object.create(null)
  return ((str: string) => {
    const hit = cache[str]
    return hit || (cache[str] = fn(str))
  }) as T
}

export const camelize = cacheStringFunction((str: string): string => {
  return str.replace(camelizeRE, (_, c) => (c ? c.toUpperCase() : ''))
})

将 props 中的 key 转换为驼峰的形式的函数是 camelize 。该函数利用闭包来做缓存,提升了函数的性能。在平时的开发中,我们也可以借鉴这种方式,来提升自己编写函数的性能。

正则表达式 /-(\w)/g 会匹配中划线(-)和单个字符(字母、数字或者下划线)。

validatePropName 函数用于判断用户传入的 props 中的 key 是否合法

ts 复制代码
// packages/runtime-core/src/componentProps.ts

function validatePropName(key: string) {
  if (key[0] !== '$') {
    return true
  } else if (__DEV__) {
    warn(`Invalid prop name: "${key}" is a reserved property.`)
  }
  return false
}

可以从源码中发现,props 中的 key 不能以 $ 符号开头,$ 符号开头的 key 都为 Vue 内部保留的 key 。

Vue 会将需要进行布尔转换(boolean casting)和计算默认值的 prop 存入 needCastKeys 数组。

ts 复制代码
for (const key in raw) {
  // 省略其他代码
  if (prop) {
    const booleanIndex = getTypeIndex(Boolean, prop.type)
    const stringIndex = getTypeIndex(String, prop.type)
    prop[BooleanFlags.shouldCast] = booleanIndex > -1
    prop[BooleanFlags.shouldCastTrue] =
      stringIndex < 0 || booleanIndex < stringIndex

    // if the prop needs boolean casting or default value
    if (booleanIndex > -1 || hasOwn(prop, 'default')) {
      needCastKeys.push(normalizedKey)
    }
  }
}
  • booleanIndex 如果大于等于 0 (即大于 -1),则传入的 prop 为 boolean 类型;否则,传入的 prop 非 boolean 类型。因此,当 booleanIndex > -1 时,需要将 shouldCast 设置为 true

  • stringIndex 如果大于等于 0(即大于 -1),则传入的 prop 为 string 类型;否则,传入的 prop 非 string 类型。如果 prop 非 string 类型(即 stringIndex < 0)或者是 string 类型但是非 boolean 类型(即 booleanIndex < stringIndex)则需要将 shouldCastTrue 设置为 true 。

这主要用于处理 prop 为 boolean 类型,但是用户却省略了传值的情况,详情见这个 issue: Boolean props conversions don't work,如下面的情况:

html 复制代码
<script src="../../dist/vue.global.js"></script>
<!-- 子组件 -->
<script type="text/x-template" id="grid-demo">
  <div>
    hello {{ langContent }}
  </div>
</script>
<script>
const Demo = {
  template: '#grid-demo',
  props: {
    'lang-content': {
      type: Boolean,
    },       
  }
}
</script>

<!-- 父组件 -->
<div id="demo">
  <demo lang-content />
</div>
<script>
Vue.createApp({
  components: {
    Demo
  }    
}).mount('#demo')
</script>
ts 复制代码
// packages/runtime-core/src/componentProps.ts

const enum BooleanFlags {
  shouldCast, // 需要转换为布尔值(boolean)的标识
  shouldCastTrue // 需要转换为 true 的标识
}

getType 函数的作用是获取给定参数的构造函数名。正则表达式 /^\s*function (\w+)/ 可用于匹配函数名。

ts 复制代码
// packages/runtime-core/src/componentProps.ts

function getType(ctor: Prop<any>): string {
  const match = ctor && ctor.toString().match(/^\s*function (\w+)/)
  return match ? match[1] : ctor === null ? 'null' : ''
}

function isSameType(a: Prop<any>, b: Prop<any>): boolean {
  return getType(a) === getType(b)
}

function getTypeIndex(
  type: Prop<any>,
  expectedTypes: PropType<any> | void | null | true
): number {
  if (isArray(expectedTypes)) {
    return expectedTypes.findIndex(t => isSameType(t, type))
  } else if (isFunction(expectedTypes)) {
    return isSameType(expectedTypes, type) ? 0 : -1
  }
  return -1
}

isArray 函数用于判断是否为数组。

isFunction 函数用于判断是否为函数

ts 复制代码
export const isArray = Array.isArray
export const isFunction = (val: unknown): val is Function =>
  typeof val === 'function'

normalizePropsOptions 函数收集到 needCastKeys 后,会在 setFullProps 函数中使用 resolvePropValue 函数对 needCastKeys 中的 key 进行转换为布尔值(boolean)或者求取默认值。

ts 复制代码
// packages/runtime-core/src/componentProps.ts

function setFullProps(
  instance: ComponentInternalInstance,
  rawProps: Data | null,
  props: Data,
  attrs: Data
) {

  // 省略其他代码
  if (needCastKeys) {
    const rawCurrentProps = toRaw(props)
    const castValues = rawCastValues || EMPTY_OBJ
    for (let i = 0; i < needCastKeys.length; i++) {
      const key = needCastKeys[i]
      props[key] = resolvePropValue(
        options!,
        rawCurrentProps,
        key,
        castValues[key],
        instance,
        !hasOwn(castValues, key)
      )
    }
  }
}

resolvePropValue 函数各入参的含义

  • options,组件的标准化的 props

  • props,组件接收到的 props

  • key,当前 prop 的 key 值

  • value,当前 prop 的值

  • instance,见名思意,组件的实例对象

  • isAbsent,当前 prop 是否缺失

ts 复制代码
function resolvePropValue(
  options: NormalizedProps,
  props: Data,
  key: string,
  value: unknown,
  instance: ComponentInternalInstance,
  isAbsent: boolean
) {
  const opt = options[key]
  if (opt != null) {
    const hasDefault = hasOwn(opt, 'default')
    // 求取 prop 的默认值
    if (hasDefault && value === undefined) {
      const defaultValue = opt.default
      if (opt.type !== Function && isFunction(defaultValue)) {
        const { propsDefaults } = instance
        if (key in propsDefaults) {
          // 读取已经缓存的默认值
          value = propsDefaults[key]
        } else {
          setCurrentInstance(instance)
          // 如果 prop 的默认值为函数类型,
          // 调用该函数求取 prop 的默认值,
          // 将求取到的默认值缓存到 propsDefaults
          value = propsDefaults[key] = defaultValue.call(
            __COMPAT__ &&
              isCompatEnabled(DeprecationTypes.PROPS_DEFAULT_THIS, instance)
              ? createPropsDefaultThis(instance, props, key)
              : null,
            props
          )
          unsetCurrentInstance()
        }
      } else {
        value = defaultValue
      }
    }
    // 对 prop 进行布尔转换
    if (opt[BooleanFlags.shouldCast]) {
      if (isAbsent && !hasDefault) {
        // shouldCast 为 true ,当前 prop 缺失并且没有默认值,
        // 则将 prop 值转换为 false
        value = false
      } else if (
        opt[BooleanFlags.shouldCastTrue] &&
        (value === '' || value === hyphenate(key))
      ) {
        // shouldCastTrue 为 true ,prop 值为空字符串
        // 或 key 转换为连字符分隔的字符串后与 value 相同
        // 则将 prop 转换为 true
        value = true
      }
    }
  }
  return value
}

\B,匹配一个非单词边界。具体可见 正则表达式 - JavaScript | MDN

([A-Z]),表示匹配一个大写字母,并将其捕获为一个分组

g,表示全局匹配

这个正则表达式会匹配所有非单词边界前的大写字母,并将其作为分组进行匹配。在实际使用中,我们可以通过替换操作将匹配到的大写字母替换为连字符加小写字母的形式,从而实现驼峰式命名到连字符分隔的转换。

ts 复制代码
// packages/shared/src/index.ts

const hyphenateRE = /\B([A-Z])/g

export const hyphenate = cacheStringFunction((str: string) =>
  str.replace(hyphenateRE, '-$1').toLowerCase()
)

const cacheStringFunction = <T extends (str: string) => string>(fn: T): T => {
  const cache: Record<string, string> = Object.create(null)
  return ((str: string) => {
    const hit = cache[str]
    return hit || (cache[str] = fn(str))
  }) as T
}

hyphenate 函数的作用就是将驼峰式命名转换为连字符分隔命名

ts 复制代码
const str = "myPropertyName"
const result = hyphenate(str)
console.log(result) // 输出:"my-property-name"

综合上面的分析,可以看到 Vue 为了完善 props 机制,编写了大量边界代码。但本质上来说,其原理都是根据组件的 props 选项定义以及为组件传递的 props 数据来处理的。

组件的 props 数据先会被标准化(normalizePropsOptions 函数),标准化过程中会将 prop 的键值(key)转换为驼峰式命名风格,同时收集需要布尔化或求取默认值的 prop ,然后标准化后的 prop 会被存入组件实例对象的 propsOptions 属性中。

当组件的 props 变更后,会在 updateComponent 函数中完成组件的更新。

ts 复制代码
// packages/runtime-core/src/renderer.ts

const updateComponent = (n1: VNode, n2: VNode, optimized: boolean) => {
  const instance = (n2.component = n1.component)!
  if (shouldUpdateComponent(n1, n2, optimized)) {
    // 省略其他代码
  } else {
    // no update needed. just copy over properties
    n2.el = n1.el
    instance.vnode = n2
  }
}

shouldUpdateComponent 函数用于检测子组件是否真的需要更新,因为子组件的 props 可能是不变的。

ts 复制代码
// packages/runtime-core/src/componentRenderUtils.ts
export function shouldUpdateComponent(
  prevVNode: VNode,
  nextVNode: VNode,
  optimized?: boolean
): boolean {
  const { props: prevProps, children: prevChildren, component } = prevVNode
  const { props: nextProps, children: nextChildren, patchFlag } = nextVNode
  // 省略其他代码
  if (optimized && patchFlag >= 0) {
    // 省略其他代码
    if (patchFlag & PatchFlags.FULL_PROPS) {
      if (!prevProps) {
        return !!nextProps
      }
      // presence of this flag indicates props are always non-null
      return hasPropsChanged(prevProps, nextProps!, emits)
    } else if (patchFlag & PatchFlags.PROPS) {
      const dynamicProps = nextVNode.dynamicProps!
      for (let i = 0; i < dynamicProps.length; i++) {
        const key = dynamicProps[i]
        // 对比 nextProps 与 prevProps
        if (
          nextProps![key] !== prevProps![key] &&
          !isEmitListener(emits, key)
        ) {
          return true
        }
      }
    }    
  }
  return false
}

isEmitListener 函数用于判断传入的 prop 是否属于 emit 事件的监听器,如果为 emit 事件的监听器则忽略该 prop 的更新。

ts 复制代码
// packages/runtime-core/src/componentEmits.ts

export function isEmitListener(
  options: ObjectEmitsOptions | null,
  key: string
): boolean {
  if (!options || !isOn(key)) {
    return false
  }

  if (__COMPAT__ && key.startsWith(compatModelEventPrefix)) {
    return true
  }

  key = key.slice(2).replace(/Once$/, '')
  return (
    hasOwn(options, key[0].toLowerCase() + key.slice(1)) ||
    hasOwn(options, hyphenate(key)) ||
    hasOwn(options, key)
  )
}

hasPropsChanged 函数则用于判断组件 props 是否有更新

ts 复制代码
// packages/runtime-core/src/componentRenderUtils.ts

function hasPropsChanged(
  prevProps: Data,
  nextProps: Data,
  emitsOptions: ComponentInternalInstance['emitsOptions']
): boolean {
  // 获取 nextProps 的所有 key
  const nextKeys = Object.keys(nextProps)
  // 如果 nextProps 的 key 数量与 prevProps 的 key 数量不相等,
  // 说明 props 有更新,则不需要进一步比较
  if (nextKeys.length !== Object.keys(prevProps).length) {
    return true
  }
  // 逐一比较 prevProps 和 nextProps 中的值,
  // 同时借助 isEmitListener 函数过滤掉 emit 事件的监听器,
  // 如果存在不相同的 prop 值,则说明 props 有更新
  for (let i = 0; i < nextKeys.length; i++) {
    const key = nextKeys[i]
    if (
      nextProps[key] !== prevProps[key] &&
      !isEmitListener(emitsOptions, key)
    ) {
      return true
    }
  }
  // 代码运行到这,props 没有更新,返回 false
  return false
}

当确认 props 更新后,会调用 updateProps 函数更新 props

ts 复制代码
// packages/runtime-core/src/componentProps.ts

export function updateProps(
  instance: ComponentInternalInstance,
  rawProps: Data | null,
  rawPrevProps: Data | null,
  optimized: boolean
) {
  const {
    props,
    attrs,
    vnode: { patchFlag }
  } = instance
  // 省略其他代码
  if (patchFlag & PatchFlags.PROPS) {
    const propsToUpdate = instance.vnode.dynamicProps!
    for (let i = 0; i < propsToUpdate.length; i++) {
      let key = propsToUpdate[i]
      if (isEmitListener(instance.emitsOptions, key)) {
        continue
      }
      const value = rawProps![key]
      if (options) {
        // 省略其他代码
        const camelizedKey = camelize(key)
        // 更新 prop
        props[camelizedKey] = resolvePropValue(
          options,
          rawCurrentProps,
          camelizedKey,
          value,
          instance,
          false /* isAbsent */
        )        
      }
    }
  }
}

总结

由组件 props 变更引起的更新为组件的被动更新,props 本质上是父组件的数据,因此父组件会产生自更新。组件自身内部状态变更引起的更新为自更新。

组件的 props 数据先会被标准化(normalizePropsOptions 函数),标准化过程中会将 prop 的键值(key)转换为驼峰式命名风格,同时收集需要布尔化或求取默认值的 prop ,然后标准化后的 prop 会被存入组件实例对象的 propsOptions 属性中。

当组件的 props 变更后,会在 updateComponent 函数中完成组件的更新。在组件更新前会调用 shouldUpdateComponent 函数判断组件是否真的需要更新,因为 props 可能没有变化,同时要过滤掉 emit 事件监听器。当最后确认 props 发生了变更后,会调用 updateProps 函数更新 prop 值。

参考

《Vue.js 设计与实现》霍春阳·著

相关推荐
程序员爱技术1 小时前
Vue 2 + JavaScript + vue-count-to 集成案例
前端·javascript·vue.js
并不会2 小时前
常见 CSS 选择器用法
前端·css·学习·html·前端开发·css选择器
衣乌安、2 小时前
【CSS】居中样式
前端·css·css3
兔老大的胡萝卜2 小时前
ppk谈JavaScript,悟透JavaScript,精通CSS高级Web,JavaScript DOM编程艺术,高性能JavaScript pdf
前端·javascript
低代码布道师2 小时前
CSS的三个重点
前端·css
耶啵奶膘4 小时前
uniapp-是否删除
linux·前端·uni-app
王哈哈^_^5 小时前
【数据集】【YOLO】【目标检测】交通事故识别数据集 8939 张,YOLO道路事故目标检测实战训练教程!
前端·人工智能·深度学习·yolo·目标检测·计算机视觉·pyqt
cs_dn_Jie6 小时前
钉钉 H5 微应用 手机端调试
前端·javascript·vue.js·vue·钉钉
开心工作室_kaic6 小时前
ssm068海鲜自助餐厅系统+vue(论文+源码)_kaic
前端·javascript·vue.js
有梦想的刺儿7 小时前
webWorker基本用法
前端·javascript·vue.js