源码剖析ref和reactive的区别

掘金总是有人抵触ref,抵触reactive,说ref有个value很丑,reactive有类型限制等等,其实没什么好吵的,爱用哪个用哪个。当一个组件响应式变量很多的时候我们其实用reactive更好,不多用ref更好,这仅仅是站在减轻代码量的角度。像这种话题每次春招之前都是个热点话题,同样,赶在春招前我也来沾沾这个热点,带大家从源码看看二者的区别

当我们需要在vue中创建响应式变量时,就会用上ref或者reactive,在用法上,reactive将引用类型变成响应式,ref通常用于将原始类型变成响应式,但是ref也能把引用类型变成响应式

reactive源码

我们先看下reactive,这里一个情景,点击按钮累加

xml 复制代码
<template>
  <div>
    <p>{{state.count}}</p>
    <button @click="() => state.msg++">add</button>
  </div>
</template>

<script setup>
import { reactive } from 'vue'

const state = reactive({ 
  count: 1
})
</script>

<style lang="css" scoped>

</style>

v8引擎在把这个变量进行重新赋值的操作,但是更改值会让浏览器重新渲染页面。

好,我们接下来自己手搓一个reactive,引入自己写的reactive源码也要能实现上面的效果,也就是说可以驱使浏览器进行二次渲染并且不刷新页面

我们写的vue代码会被vue源码中的编译器给编译成html代码,然后拿给浏览器去渲染,然后我们写的代码发生了变更,vue需要一个监听器去实时监听是否发生了变更,一旦变更就调用vue编译器去编译我们的代码再送给浏览器渲染,当js发生变更,浏览器默认就会重新渲染。

所以整个响应式原理,其核心就是监听器

接下来我就自己手搓一个reactive,来到src新建一个reactivity目录,这里面放所有关于响应式的代码

reactivity下新建一个reactive.js文件,我们在这里写reactive源码

reactive将引用类型变成响应式,这个任务交给createReactiveObject来完成

javascript 复制代码
export function reactive(target) { // 将target变成响应式
  return createReactiveObject(target)
}

createReactiveObject抛不抛出都可以,但是yyx考虑得更多,可能需要拿到别的地方用,这里也抛出了

接受的参数必须是对象

javascript 复制代码
export function createReactiveObject(target, proxyMap, proxyHandlers) { // 创建响应式的函数
  // 判断target是不是一个引用类型
  if (typeof target !== 'object' || target === null) {  // 不是对象就不给操作
    return target
  }
}

既然reactive是将一个引用类型变成响应式,那么就需要先进行判断下数据类型。如何判断一个变量是否为引用类型,可以直接用typeOftypeof可以判断除null之外的原始类型,另外还可以判断function

我们可以看下yyx这里是怎么写的,yyx专门写了个工具函数用来判断对象

github.com/vuejs/core/...

yyx也是这么写的

避免二次响应式

比如下面的代码

ini 复制代码
const state = reactive({ 
  count: 1
})

const state2 = reactive(state) 

一个对象已经响应式了,为何再次响应式,这是多此一举

所以我们需要避免有小可爱这样写代码

如何判断一个对象是否曾经被代理过?我们可以用一个参数来存放被代理过的对象,这里用WeakMap创建这个参数,然后作为实参传到函数createReactiveObject中去

WeakMap是弱化版的map,而map又是es6提供的类数组,它是个强版本的对象,其key可以是任意类型,WeakMap对内存的回收会更友好

13. Set 和 Map 数据结构 - WeakMap - 《阮一峰 ECMAScript 6 (ES6) 标准入门教程 第三版》 - 书栈网 · BookStack

javascript 复制代码
export const reactiveMap = new WeakMap() // 保存被代理过的对象

export function createReactiveObject(target, proxyMap) { // 创建响应式的函数
  // 判断target是不是一个引用类型
  if (typeof target !== 'object' || target === null) {  // 不是对象就不给操作
    return target
  }
  // 该对象是否已经被代理过(已经是响应式对象)
  const existingProxy = proxyMap.get(target) // WeakMap读取值用get方法
  if (existingProxy) {
    return existingProxy
  }
}

接下来才是核心代码了,也就是将target变成响应式

target变成响应式

将对象变成响应式主要依靠es的Proxy方法,变成响应式就是代理它

proxy就是拦截一个对象,然后对对象进行一系列操作

Proxy - ECMAScript 6入门 (ruanyifeng.com)

new Proxy接收两个参数,第一个参数是你要代理的对象,当这个对象被读取到时,第二个参数中的get就会被触发,修改时set函数会被触发,判断是否有值时,has会被触发......总共有13个方法

所以这里核心代码如下,下面的proxy就是代理后的对象

javascript 复制代码
const proxy = new Proxy(target, {
	get: function () {
		// ......
	},
	// ......
})

第二个参数中的13个函数全部写这里会很丑,我就拿出去写,作为参数传进来,这个参数的作用就是当target被读值,设置值,判断值等等操作时会触发对应的一系列函数

参数二的这些函数我放入reactivity/baseHandlers.js中,如下

arduino 复制代码
export const mutableHandlers = {
    get,
    set
}

然后reactive.js中引入,作为实参传入。

好了,已经有了代理后的对象proxy了,那么就需要将其存入到WeakMap中,其方法为set,最终reactive.js完整代码如下

javascript 复制代码
import { mutableHandlers } from './baseHandlers.js'
// 保存被代理过的对象
export const reactiveMap = new WeakMap() // new Map() // new WeakMap 对内存的回收更加友好

export function reactive(target) { // 将target变成响应式
  return createReactiveObject(target, reactiveMap, mutableHandlers)
}

export function createReactiveObject(target, proxyMap, proxyHandlers) { // 创建响应式的函数
  // 判断target是不是一个引用类型
  if (typeof target !== 'object' || target === null) {  // 不是对象就不给操作
    return target
  }

  // 该对象是否已经被代理过(已经是响应式对象)
  const existingProxy = proxyMap.get(target)
  if (existingProxy) {
    return existingProxy
  }

  // 执行代理操作(将target处理成响应式)
  const proxy = new Proxy(target, proxyHandlers) // 第二个参数的作用:当target被读取值,设置值,判断值等等操作时会触发的函数

  // 往 proxyMap 增加 proxy, 把已经代理过的对象缓存起来
  proxyMap.set(target, proxy)
  
  return proxy
}

代码写到这里,其核心代码就是一个new Proxy一行代码......

其实这里你也就可以明白为什么reactive是能代理引用类型了,因为proxy只能代理引用类型

好了,接下来就来写,对象如何被代理,完善baseHandlers.js

baseHandlers.js

vue响应式的效果其实就是读值,修改值,所以仅需要完善13个函数中的getset即可

javascript 复制代码
export const mutableHandlers = {
    get: function () {
        console.log('读值');
    },
    set: function () {
        console.log('修改值');
    }
}

我们可以先测试下,在两个函数中放个打印,然后写个测试文件去修改值,看是否能够打印,text.html如下

xml 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <script type="module"> 
    // ESModule语法,这样浏览器才能读懂模块化
        import { reactive } from './reactive.js'

        const state = reactive({
            name: 'dolphin',
            age: 18
        })

        setInterval(() => {
            state.age++  // 赋值前会读值,因此按道理一定打印两个值
        }, 2000)
    </script>
</body>
</html>

没有问题,这里报错是因为读值读不到,因为get没有返回值给你读,另外也无法作修改,set还没写

get中有三个参数,分别为目标对象目标对象修改的key代理后的对象

vbnet 复制代码
get: function (target, key, receiver) {}

我们需要读到state里面的age,也就是对象的key,所以就是state[key]

因此get如下

vbnet 复制代码
export const mutableHandlers = {
    get: function (target, key, receiver) {
        return target[key]
    }
}

set中有四个参数,原对象原对象修改的keykey对应的value值代理后的对象

vbnet 复制代码
set: function (target, key, value, receiver) {}

想要实现age++,就必须要实现修改,修改之后还需要将值赋回去

所以我们可能会加上个target[key] = value

但是实现响应式的核心不是仅仅告诉v8,我们的值变了,还需要浏览器去更新试图

其实读取值这里更好是用es6新方法Reflect

reflect其实就是新版本的Object,为何要新搞一个reflect,这是因为Object身上已经有很多属性了,更新到es6的时候还想着给Object身上挂方法,考虑到维护性和优雅性,包括es6以及es6之后想要给Object新增的方法都统统挂在了reflect身上了

当然,新搞一个reflect的原因还有就是原属于Object身上的方法有些是由小瑕疵的,诸如Object.defineProperty的报错问题,而Reflect.defineProperty不会出现程序性问题

Reflect有个get方法,就是用来读取对象的,还有个get方法,就是用来修改值的,通常搭配proxy一起使用。Reflect.get有三个参数,分别为target,key,receiverproxy中的参数二函数get的参数一致,get同理

因此两个函数getset方法用上Reflect如下

vbnet 复制代码
export const mutableHandlers = {
    get: function(target, key, receiver) {  
        const res = Reflect.get(target, key, receiver)
        return res
    },
    set: function(target, key, value, receiver) {
        const res = Reflect.set(target, key, value, receiver)
        return res
    }   
}

好了,此时的age++实现起来就不会报错了

为了让代码更加优雅,我将这两个函数分出去写,写成闭包的形式

vbnet 复制代码
const get = createGetter() 
const set = createSetter()

export const mutableHandlers = {
    get,
    set
}

function createGetter() { 
    return function get(target, key, receiver) {
        console.log('target对象被读取值了');
        const res = Reflect.get(target, key, receiver) 
        return res
    }
}

function createSetter() {
    return function set(target, key, value, receiver) {   
        console.log('target对象被修改值了', key, value);
        const res = Reflect.set(target, key, value, receiver)
        return res
    }
}

还是最开始的那个使用场景,如果我添加上computedwatch,如下

xml 复制代码
<template>
  <div>
    <p>{{state.count}}</p>
    <p>{{num}}</p>
    <button @click="() => state.count++">add</button>
  </div>
</template>

<script setup>
import { reactive, computed, watch } from 'vue'

const state = reactive({ // 响应式的对象
  count: 1
})

const num = computed(() => {
  return state.count * 10
})

watch(
  () => state.count, 
  (newVal, oldVal) => {
    console.log(newVal, oldVal);
  }
)
</script>

<style lang="css" scoped>

</style>

如果我点击按钮,那么computed计算属性也是会重新执行的,watch监听器同理,那么这个效果是如何实现的呢

其实说这个就是要讲副作用函数,一个响应式的数据,是有可能被像是computed这样的东西去使用的,如果一个响应式数据没有任何人去使用,那么像是computed就不需要去执行了

用到了这个响应式数据的函数就是副作用函数,比如常见的computed,watch

还有个watchEffect函数,watch想要初次加载需要设置immediatetrue,但是watchEffect默认行为就是初次加载,并且一个很大区别是watchEffect不需要监听什么东西就会执行,而watch必须要监听到数据的变更才能执行,当然watchEffect同样可以监听一个数据,变更后同样执行,并且watchEffect还可以在变量发生变更的时候再做些什么,等到dom渲染完成可以触发onTrackonTrigger函数,这两个函数称之为调度函数,调度函数充当了副作用函数,二者不能同时触发

收集副作用就叫做依赖收集,需要记录下来此时是哪一个key(就是state[key])的值变更了,再去通知其他依赖该值的函数生效,这有点像是发布订阅,订阅变量的函数才会触发

因此这里我们就需要在get函数中写一个发布,其实依赖收集就是触发每一个被修改的属性身上的副作用函数

依赖收集是vue2的说法,后面的解释是vue3的说法

因此我们先要需要清楚这个响应式数据身上有哪些地方被用到了,之后再去触发它

副作用函数收集为何放在get中去完成,因为有些响应式数据,可能并没有出现在其他地方使用,因此这个效果就是哪里用上了就给它去收集,而get就是读值的操作

副作用收集这里我专门拿出来写,reactivity/effect.js

effect.js

同样,用WeakMap来收集,并且需要避免二次收集

scss 复制代码
const targetMap = new WeakMap() // 和Map本质区别就是一个性能问题,key能写成其他类型

let activeEffect = null // 副作用函数

// 为某个属性添加 effect 副作用
export function track(target, key) {  // 为某个属性添加effect函数
    // 避免二次收集
    let depsMap = targetMap.get(target)
    if (!depsMap) { // 初次读取到值 收集effect
        depsMap = new Map()
        targetMap.set(target, depsMap)
    }
    let deps = depsMap.get(key)
    if (!deps) {  // 该属性还未添加effect
        deps = new Set() // set可保证不会为重复收集
    }
    if (!deps.has(activeEffect) && activeEffect) { // 存入一个effect函数
        deps.add(activeEffect)
    }
    depsMap.set(key, deps)
}

这个track函数就是添加副作用函数,读一次值就会调用一次

因此把它引入到baseHandlers.js中,放到get里面

vbnet 复制代码
import { track } from './effect.js'

const get = createGetter() 
const set = createSetter()

export const mutableHandlers = {
    get,
    set
}

function createGetter() { 
    return function get(target, key, receiver) {
        console.log('target对象被读取值了');
        const res = Reflect.get(target, key, receiver) 
        track(target, key)
        return res
    }
}

function createSetter() {
    return function set(target, key, value, receiver) {   
        console.log('target对象被修改值了', key, value);
        const res = Reflect.set(target, key, value, receiver)
        return res
    }
}

但是目前effect.jstrack收集到的副作用还是null

其实vue里面还真有个effect函数,这个函数你在vue文档中是找不到的,因为它是打造给自己的源码使用的,effectcomputed一样,需要引入,又有点像是钩子,一开始就会执行,并且同监听器一样,响应式数据发生变更也会执行,并且里面还可以写调度函数,依赖响应式数据变化而执行,调度函数执行了副作用函数不会执行

javascript 复制代码
import { effect } from 'vue' 

effect(
  () => {  // 类似生命周期,一开始就会执行,但是这个东西yyx是作依赖收集用的,官方文档没有解释 ,参二若为true,不执行,可控
  console.log('effect', state.count);
  },
  {
    lazy: false,
    scheduler: () => {
      console.log('调度器依赖响应式变化而执行');
    }
  }
)

因此这里我们需要给副作用函数activeEffect赋值

javascript 复制代码
const targetMap = new WeakMap() // 和Map本质区别就是一个性能问题,key能写成其他类型

let activeEffect = null // 副作用函数

export function effect(fn, options={}) { 
    const effectFn = () => {
        try {
            activeEffect = effectFn
            return fn()
        } finally {
            activeEffect = null
        }
    }
    if (!options.lazy) { // lazy为false才会触发
        effectFn()
    }
    return effectFn
}

// 为某个属性添加 effect 副作用
export function track(target, key) {  // 为某个属性添加effect函数
    // 避免二次收集
    let depsMap = targetMap.get(target)
    if (!depsMap) { // 初次读取到值 收集effect
        depsMap = new Map()
        targetMap.set(target, depsMap)
    }
    let deps = depsMap.get(key)
    if (!deps) {  // 该属性还未添加effect
        deps = new Set() // set可保证不会为重复收集
    }
    if (!deps.has(activeEffect) && activeEffect) { // 存入一个effect函数
        deps.add(activeEffect)
    }
    depsMap.set(key, deps)
}

try finally不同于try catchfinally不管try执行与否都会执行

写到这里,effect还有个效果没有体现,就是数据发生变更也能执行,目前只能执行一遍,因此需要再写一个触发函数,触发所有的副作用函数,因此去遍历即可,另外再添上调度器函数的执行,最终effect.js如下

javascript 复制代码
// 副作用收集相关代码

const targetMap = new WeakMap() // 和Map本质区别就是一个性能问题,key能写成其他类型

let activeEffect = null  // 得是一个副作用函数

export function effect(fn, options={}) { // fn是箭头函数
    const effectFn = () => {
        try {
            activeEffect = effectFn
            return fn()
        } finally {
            activeEffect = null
        }
    }
    if (!options.lazy) {
        effectFn()
    }
    effectFn.scheduler = options.scheduler
    return effectFn
}

// 为某个属性添加 effect 副作用
export function track(target, key) {  
    // 避免二次收集
    let depsMap = targetMap.get(target)
    if (!depsMap) { // 初次读取到值 收集effect
        depsMap = new Map()
        targetMap.set(target, depsMap)
    }
    let deps = depsMap.get(key)
    if (!deps) {  // 该属性还未添加effect
        deps = new Set()
    }
    if (!deps.has(activeEffect) && activeEffect) { // 存入一个effect函数
        deps.add(activeEffect)
    }
    depsMap.set(key, deps)
    // console.log(depsMap);
}

// 触发某个属性effect
export function trigger(target, key) {
    const depsMap = targetMap.get(target)
    if (!depsMap) { // 当前对象所有key中都没有依赖,也就是从来没有使用过
        return 
    }
    const deps = depsMap.get(key)
    if (!deps) { // 这个属性没有依赖
        return 
    }

    deps.forEach(effectFn => {
        if (effectFn.scheduler) { // 调度函数不能与副作用函数同时触发
            effectFn.scheduler()
        } else {
            effectFn()
        }
    })
}

触发副作用函数的时机就是修改值的时候,因此这个触发副作用trigger需要引入到set

最终baseHandlers.js如下

vbnet 复制代码
import { track, trigger } from './effect.js'

const get = createGetter() 
const set = createSetter()

export const mutableHandlers = {
    get,
    set
}

function createGetter() { 
    return function get(target, key, receiver) {
        console.log('target对象被读取值了');
        const res = Reflect.get(target, key, receiver) 
        track(target, key)
        return res
    }
}

function createSetter() {
    return function set(target, key, value, receiver) {   
        console.log('target对象被修改值了', key, value);
        const res = Reflect.set(target, key, value, receiver)
        trigger(target, key)
        return res
    }
}

其实从用法和效果上来看,effectwatch以及computed是一样的,其实watchcomputed的源码确实是这样的,核心就是effect函数

最终reactive源码目录如下

总结

一段话描述reactive源码:用Proxy代理了对象,在代理函数get中对使用了的属性做副作用函数收集,在代理函数set中对修改了的属性做副作用函数的触发。这样你也就明白了为什么用到了reactive的数据,数据发生变更后,computedwatch也能够自动执行

ref源码

首先我们要明白,ref通常是给原始数据类型作响应式的,但是只要是被ref过了,那么这个数据一定是个对象,比如我写const age = ref(18),那么age.value是18,但是age是个对象

再来一个小知识,js的对象是有gettersetter的能力,比如下面的对象,我想要打印18,就需要写成obj.age()

javascript 复制代码
let obj = {
	name: 'Tom',
	age() { // get age() {} 
		return 18
	}
}

console.log(obj.age())

如果我给函数前面加一个get关键字,那么直接obj.age就可以读取到,修改一个对象中的值时,如果这个值前有set关键字是个函数时,这个值可以当成属性来修改

好了,这个时候你就明白了,age.value的这个value其实就是用的get

将数据变成响应式需要用构造函数

kotlin 复制代码
export function ref (val) {
    return createRef(val)
}

function createRef(val) { // 将原始数据类型变成响应式
    // 避免二次响应
    
    // 将val变成响应式
    return new RefImpl(val)
}

class RefImpl { // es6构造函数class写法
    constructor(val) {
        this.__v_isRef = true // 给每一个被ref操作的属性添加标记
        this._value = val // _开头的属性通常表示私有属性,源码自用
    }

    get value() { // 函数前一个get表示,让一个函数不用()就可以调用,如果没这个get,那么就是age.value()才能拿到值
		return this._value
    }
}

其实我们很希望ref后的age,拿到其数据,直接是age,而不是age.value,这样感觉很难看,yyx团队也无法解决这个问题~,对象访问必须加一个.

ref目前的写法是没有实现响应式的,我们需要为this进行依赖收集和触发依赖,this就是代表实例对象,这两个功能这里就可以套用reactive里面写好的tracktrigger函数,如下

kotlin 复制代码
import { track, trigger } from './effect.js'

export function ref (val) {
    return createRef(val)
}

function createRef(val) { // 将原始数据类型变成响应式
    // 避免二次响应
    
    // 将val变成响应式
    return new RefImpl(val)
}

class RefImpl { // es6构造函数class写法
    constructor(val) {
        this.__v_isRef = true // 给每一个被ref操作的属性添加标记
        this._value = val // _开头的属性通常表示私有属性,源码自用
    }

    get value() { // 函数前一个get表示,让一个函数不用()就可以调用,如果没这个get,那么就是age.value()才能拿到值
        // 为this对象作依赖收集
        track(this, 'value') // 参二key是人为加的
        return this._value
    }

    set value(newVal) {
        if (newVal !== this._value) {
            this._value = newVal
            trigger(this, 'value') // 触发掉'value'上的所有副作用函数
        }
    }
}

但是,我前面说了,ref只是通常用作响应式原始类型,引用类型也是可以的,比如下面我这样用,打印下看看

xml 复制代码
<template>
  <div>
    <p>{{age.num}}</p>
    <button @click="() => age.num++">add</button>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const age = ref({num: 18})
console.log(age.value)
</script>

<style lang="css" scoped>

</style>

啊?是个Proxy,这不就是相当于ref里面内置了个reactive吗,这么看来ref的能力是要强点的

我说这句话会不会被骂?先别骂

好了,既然如此,那我只要是碰到引用类型就让reactive帮忙就可以,另外完善避免二次响应,最终ref完整代码如下

kotlin 复制代码
import { track, trigger } from './effect.js'
import { reactive } from './reactive.js'

export function ref (val) {
    return createRef(val)
}

function createRef(val) { // 将原始数据类型变成响应式
    // 避免二次响应
    if (val.__v_isRef) {
        return // 已经响应式不予以响应
    }
    // 将val变成响应式
    return new RefImpl(val)
}

class RefImpl { // es6构造函数class写法
    constructor(val) {
        this.__v_isRef = true // 给每一个被ref操作的属性添加标记
        this._value = convert(val) // _开头的属性通常表示私有属性,源码自用
    }

    get value() { // 函数前一个get表示,让一个函数不用()就可以调用,如果没这个get,那么就是age.value()才能拿到值
        // 为this对象作依赖收集
        track(this, 'value') // 参二key是人为加的
        return this._value
    }

    set value(newVal) {
        if (newVal !== this._value) {
            this._value = convert(newVal)
            trigger(this, 'value') // 触发掉'value'上的所有副作用函数
        }
    }
}

function convert(val) {
    if (typeof val !== 'object' || val === null) { // 不是对象
        return val // 原始类型就用自身
    } else {
        return reactive(val) // 引用类型喊reactive帮忙
    }
}

总结

ref通常将原始类型变成响应式,依赖收集和触发依赖用的是js对象自带的getset语法,但是如果ref接收到的是引用类型,依旧是靠reactive使用

最后

面试官:请你说收ref和reactive的区别

reactive用于响应式引用类型,靠的是es6Proxy代理方法,这个方法的参数二自带getset方法分别用于依赖收集和依赖触发,依赖收集就是收集其副作用函数,依赖触发就是触发其副作用函数,依赖收集和依赖触发才能实现,谁用了这个响应式数据,谁就能立马发生变化,从而实现试图的更新,而ref通常用于响应式原始类型,这靠的是原生js对象的gettersetter效果为属性添加副作用函数和触发副作用函数,如果需要用它响应引用类型,ref会喊reactive来帮忙

如果你对春招感兴趣,可以加我的个人微信:Dolphin_Fung,我和我的小伙伴们有个面试群,可以进群讨论你面试过程中遇到的问题,我们一起解决

另外有不懂之处欢迎在评论区留言,如果觉得文章对你学习有所帮助,还请"点赞+评论+收藏"一键三连,感谢支持!

相关推荐
赵啸林2 分钟前
npm发布插件超级简单版
前端·npm·node.js
罔闻_spider36 分钟前
爬虫----webpack
前端·爬虫·webpack
吱吱鼠叔37 分钟前
MATLAB数据文件读写:1.格式化读写文件
前端·数据库·matlab
爱喝水的小鼠1 小时前
Vue3(一) Vite创建Vue3工程,选项式API与组合式API;setup的使用;Vue中的响应式ref,reactive
前端·javascript·vue.js
小晗同学1 小时前
Vue 实现高级穿梭框 Transfer 封装
javascript·vue.js·elementui
宇宙李1 小时前
2024java面试-软实力篇
面试·职场和发展
WeiShuai1 小时前
vue-cli3使用DllPlugin优化webpack打包性能
前端·javascript
Wandra1 小时前
很全但是超级易懂的border-radius讲解,让你快速回忆和上手
前端
forwardMyLife1 小时前
element-plus的面包屑组件el-breadcrumb
javascript·vue.js·ecmascript
ice___Cpu1 小时前
Linux 基本使用和 web 程序部署 ( 8000 字 Linux 入门 )
linux·运维·前端