> 웹 프론트엔드 > View.js > vue3에서 ref와 반응성을 사용하는 방법

vue3에서 ref와 반응성을 사용하는 방법

PHPz
풀어 주다: 2023-05-12 18:46:06
앞으로
1130명이 탐색했습니다.
      1. 서문 e Vue3에는 응답 데이터를 위한 REF 및 Reactive 두 가지 API가 추가되었습니다. REF 시리즈는 의심할 여지 없이 데이터 변경을 의미하는 가장 자주 사용되는 API 중 하나입니다. 데이터 유형에는 기본 데이터 유형(문자열, 숫자, 부울, 정의되지 않음, null, 기호)과 참조 데이터 유형(객체, 배열, 세트, ​​맵 등)이 포함됩니다. js의 모든 데이터 유형 변경을 정확하게 감지 및 추적하고 vnode 비교 후 실제 dom의 렌더링을 달성하는 방법은 무엇입니까? vue에서는 어떻게 하나요? 간단한 예는 다음과 같습니다:

    import { reactive, ref } from "vue"; 
    import type { Ref } from "vue";
    // 定义响应式数据
    const count: Ref<number> = ref(0);
    function countClick() {
            count.value++; // 更新数据
    }
    로그인 후 복사
    // 定义引用类型数据标注
     
    interface TypeForm {
            name: string;
            num: number;
            list?: Array<[]>;
    }
    const formInline: TypeForm = reactive({
            name: "",
            num: 0,
    });
    formInline.name = &#39;KinHKin&#39;
    formInline.num = 100
    formInline.list = [1,2,3,4]
    로그인 후 복사

    렌더링:

    2. 비교

    먼저 ref와 Reactivevue3에서 ref와 반응성을 사용하는 방법

    의 일반 매개변수를 사용하는 것은 권장되지 않습니다. deep ref가 처리됩니다. 래핑되지 않은 반환 값은 일반 매개변수와 다른 유형입니다. Ref는 함수에 전달되거나 일반 개체에서 분해되며 응답을 잃지 않습니다.

    vue3에서 ref와 반응성을 사용하는 방법

    const obj = {
      foo: ref(1),
      bar: ref(2)
    }
     
    // 该函数接收一个 ref
    // 需要通过 .value 取值
    // 但它会保持响应性
    callSomeFunction(obj.foo)
     
    // 仍然是响应式的
    const { foo, bar } = obj
    로그인 후 복사

    간단히 말해서 Ref()를 사용하면 모든 값에 대한 "참조"를 생성할 수 있습니다. 응답성을 잃지 않고 이러한 참조를 전달합니다. 이 기능은 논리를 구성 함수로 추출하는 데 자주 사용되기 때문에 중요합니다.

    reactive()refs가 템플릿의 최상위 속성으로 액세스되면 자동으로 "래핑 해제"되므로 .value를 사용할 필요가 없습니다. 다음은 ref()로 대체된 이전의 카운터 예입니다.

    <script setup>
    import { ref } from &#39;vue&#39;
     
    const count = ref(0)
     
    function increment() {
      count.value++
    }
    </script>
     
    <template>
      <button @click="increment">
        {{ count }} <!-- 无需 .value -->
      </button>
    </template>
    로그인 후 복사

    자동 "래핑 해제"는 ref가 템플릿 렌더링 컨텍스트의 최상위 속성인 경우에만 적용됩니다.
    3.ref 소스 코드 분석

    vue3.2.2x 버전의 경우 소스 코드는 node_moudles/@vue/reactivity/dist/reactivity.cjs.js 파일에 있습니다

    실행 순서는 ref -> ;createRef ->new RefImpl 인스턴스 객체를 생성하고 get 및 set 메소드를 제공합니다. 소스 코드에서 기본적으로 깊은 수준의 ref와 얕은 수준의 ref에 응답하는 두 개의 함수가 입구에 있음을 알 수 있습니다. . 한 매개변수는 false이고 다른 매개변수는 true입니다.

    function ref(value) {
        return createRef(value, false);
    }
    function shallowRef(value) {
        return createRef(value, true);
    }
    로그인 후 복사

    다음 단계는 createRef 메소드를 사용하는 것입니다:

    function createRef(rawValue, shallow) {
        if (isRef(rawValue)) {
            return rawValue;
        }
        return new RefImpl(rawValue, shallow);
    }
    로그인 후 복사
    이 createRef 메소드는 두 개의 매개변수를 허용합니다. 하나는 전달된 기본 유형의 기본값이고 다른 하나는 심층 응답인지 여부에 대한 부울 값입니다. .

    function isRef(r) {
        return !!(r && r.__v_isRef === true);
    }
    로그인 후 복사

    rawValue가 ref 유형이면 rawValue가 즉시 반환되고, 그렇지 않으면 RefImpl 인스턴스가 반환됩니다.

    RefImpl 클래스:

    class RefImpl {
        constructor(value, __v_isShallow) {
            this.__v_isShallow = __v_isShallow;
            this.dep = undefined;
            this.__v_isRef = true;
            this._rawValue = __v_isShallow ? value : toRaw(value);
            this._value = __v_isShallow ? value : toReactive(value);
        }
        get value() {
            trackRefValue(this);
            return this._value;
        }
        set value(newVal) {
            const useDirectValue = this.__v_isShallow || isShallow(newVal) || isReadonly(newVal);
            newVal = useDirectValue ? newVal : toRaw(newVal);
            if (shared.hasChanged(newVal, this._rawValue)) {
                this._rawValue = newVal;
                this._value = useDirectValue ? newVal : toReactive(newVal);
                triggerRefValue(this, newVal);
            }
        }
    }
    로그인 후 복사

    RefImpl 클래스 생성자에서 __v_isShallow는 ref의 이전 값을 저장합니다. _value는 ref에서 허용하는 최신 값입니다. 공개 읽기 전용 변수 __v_isRef는 객체를 참조로 식별하는 데 사용됩니다. 반응 객체의 태그는 반응 API에 대해 말할 때 ReactiveFlag와 동일합니다.

    const toReactive = (value) => shared.isObject(value) ?active(value) : value; 내부적으로 이 함수는 전달된 객체가 객체인지 여부를 확인합니다. 그렇지 않으면 원래 매개변수가 직접 반환됩니다.

    ref.value 형식으로 ref 값을 읽으면 해당 값의 getter 메소드가 트리거됩니다. getter에서는 trackRefValue를 통해 먼저 ref 객체 값의 종속성을 수집합니다. 수집이 완료되면 ref 값이 반환됩니다.

    function trackRefValue(ref) {
        if (shouldTrack && activeEffect) {
            ref = toRaw(ref);
            {
                trackEffects(ref.dep || (ref.dep = createDep()), {
                    target: ref,
                    type: "get" /* TrackOpTypes.GET */,
                    key: &#39;value&#39;
                });
            }
        }
    }
    로그인 후 복사

    ref.value를 수정하면 값의 setter 메서드가 트리거되고 새 값과 이전 값이 비교되어 값이 달라 업데이트가 필요한 경우 이전 값과 새 값이 비교됩니다. ​먼저 업데이트된 다음 ref 객체의 value 속성이 TriggerRefValue를 통해 전달됩니다. 이 ref에 의존하는 부작용 함수가 업데이트를 수행하도록 합니다.

    function triggerRefValue(ref, newVal) {
        ref = toRaw(ref);
        if (ref.dep) {
            {
                triggerEffects(ref.dep, {
                    target: ref,
                    type: "set" /* TriggerOpTypes.SET */,
                    key: &#39;value&#39;,
                    newValue: newVal
                });
            }
        }
    }
    로그인 후 복사

    4.reactive 소스 코드 분석

    vue3.2.2x 버전의 경우 소스 코드는 node_moudles/@vue/reactivity/dist/reactivity.cjs.js 파일에 있습니다

    업데이트에 대한 전반적인 설명 vue3의 메커니즘:

    Vue3에서는 트랙의 핸들러 함수를 통해 종속성을 수집하고 트리거의 핸들러 함수를 통해 업데이트를 전달합니다. 각 종속성의 사용은 부작용 함수로 래핑됩니다. 업데이트가 전달된 후에 실행되므로 종속성 값이 업데이트됩니다.

    프록시 객체는 핸들러 트랩을 사용하여 가져오기 및 설정 중 변경 사항을 캡처할 수 있으며 배열 인덱스 변경 사항 및 배열 길이 변경 사항을 모니터링할 수도 있습니다.

    실행 순서는 다음과 같습니다:active -> createReactiveObject ->

    function reactive(target) {
        // if trying to observe a readonly proxy, return the readonly version.
        if (isReadonly(target)) {
            return target;
        }
        return createReactiveObject(target, false, mutableHandlers, mutableCollectionHandlers, reactiveMap);
    }
    로그인 후 복사

    세 번째 줄 isReadonly 함수는 객체가 읽기 전용 객체인지 여부를 결정하고, IS_READONLY 키는 객체가 읽기 전용 객체인지 여부를 결정합니다. ReactiveFlags 열거형은 소스 코드에서 계속해서 나타나므로 미리 ReactiveFlags를 도입해야 합니다.
    function isReadonly(value) {
     
    return !!(value && value["__v_isReadonly" /* ReactiveFlags.IS_READONLY */]);
     
    }
    로그인 후 복사
    export const enum ReactiveFlags {
      SKIP = &#39;__v_skip&#39;, // 是否跳过响应式 返回原始对象
      IS_REACTIVE = &#39;__v_isReactive&#39;, // 标记一个响应式对象
      IS_READONLY = &#39;__v_isReadonly&#39;, // 标记一个只读对象
      RAW = &#39;__v_raw&#39; // 标记获取原始值
      IS_SHALLOW  = &#39;__v_isShallow&#39; // 是否浅层次拷贝
    }
    로그인 후 복사

    在 ReactiveFlags 枚举中有 5 个枚举值,这五个枚举值的含义都在注释里。对于 ReactiveFlags 的使用是代理对象对 handler 中的 trap 陷阱非常好的应用,对象中并不存在这些 key,而通过 get 访问这些 key 时,返回值都是通过 get 陷阱的函数内处理的。介绍完 ReactiveFlags 后我们继续往下看。

    createReactiveObject

    入参部分:

    function createReactiveObject(target, isReadonly, baseHandlers, collectionHandlers, proxyMap) {}

    先看 createReactiveObject 函数的签名,该函数接受 5 个参数:

    1、target:目标对象,想要生成响应式的原始对象。
    2、isReadonly:生成的代理对象是否只读。
    3、baseHandlers:生成代理对象的 handler 参数。当 target 类型是 Array 或 Object 时使用该 handler。
    4、collectionHandlers:当 target 类型是 Map、Set、WeakMap、WeakSet 时使用该 handler。
    5、proxyMap:存储生成代理对象后的 Map 对象。
    这里需要注意的是 baseHandlers 和 collectionHandlers 的区别,这两个参数会根据 target 的类型进行判断,最终选择将哪个参数传入 Proxy 的构造函数,当做 handler 参数使用。

    逻辑部分:

    function createReactiveObject(target, isReadonly, baseHandlers, collectionHandlers, proxyMap) {
        // 如何不是对象 曝出警告 返回其原始值
        if (!shared.isObject(target)) {
            {
                console.warn(`value cannot be made reactive: ${String(target)}`);
            }
            return target;
        }
        // target is already a Proxy, return it.
        // exception: calling readonly() on a reactive object
     
         // 如果目标已经是一个代理,直接返回  KinHKin译
        // 除非对一个响应式对象执行 readonly
     
        if (target["__v_raw" /* ReactiveFlags.RAW */] &&
            !(isReadonly && target["__v_isReactive" /* ReactiveFlags.IS_REACTIVE */])) {
            return target;
        }
        // target already has corresponding Proxy
        // 目标已经存在对应的代理对象 KinHKin译
        const existingProxy = proxyMap.get(target);
        if (existingProxy) {
            return existingProxy;
        }
        // only specific value types can be observed.
        // 只有白名单里的类型才能被创建响应式对象  KinHKin译
        const targetType = getTargetType(target);
        if (targetType === 0 /* TargetType.INVALID */) {
            return target;
        }
        const proxy = new Proxy(target, targetType === 2 /* TargetType.COLLECTION */ ? collectionHandlers : baseHandlers);
        proxyMap.set(target, proxy);
        return proxy;
    }
    로그인 후 복사

    在该函数的逻辑部分,可以看到基础数据类型并不会被转换成代理对象,而是直接返回原始值。

    并且会将已经生成的代理对象缓存进传入的 proxyMap,当这个代理对象已存在时不会重复生成,会直接返回已有对象。

    也会通过 TargetType 来判断 target 目标对象的类型,Vue3 仅会对 Array、Object、Map、Set、WeakMap、WeakSet 生成代理,其他对象会被标记为 INVALID,并返回原始值。

    当目标对象通过类型校验后,会通过 new Proxy() 生成一个代理对象 proxy,handler 参数的传入也是与 targetType 相关,并最终返回已生成的 proxy 对象。

    所以回顾 reactive api,我们可能会得到一个代理对象,也可能只是获得传入的 target 目标对象的原始值。

    handles的组成

    在 @vue/reactive 库中有 baseHandlers 和 collectionHandlers 两个模块,分别生成 Proxy 代理的 handlers 中的 trap 陷阱。

    例如在上面生成 reactive 的 api 中 baseHandlers 的参数传入了一个 mutableHandlers 对象,这个对象是这样的:

    const mutableHandlers = {
        get,
        set,
        deleteProperty,
        has,
        ownKeys
    };
    로그인 후 복사

    通过变量名我们能知道 mutableHandlers 中存在 5 个 trap 陷阱。而在 baseHandlers 中,get 和 set 都是通过工厂函数生成的,以便于适配除 reactive 外的其他 api,例如 readonly、shallowReactive、shallowReadonly 等。

    baseHandlers 是处理 Array、Object 的数据类型的,这也是我们绝大部分时间使用 Vue3 时使用的类型,所以笔者接下来着重的讲一下baseHandlers 中的 get 和 set 陷阱。

    get陷阱

    上一段提到 get 是由一个工厂函数生成的,先来看一下 get 陷阱的种类。

    const get = /*#__PURE__*/ createGetter();
    const shallowGet = /*#__PURE__*/ createGetter(false, true);
    const readonlyGet = /*#__PURE__*/ createGetter(true);
    const shallowReadonlyGet = /*#__PURE__*/ createGetter(true, true);
    로그인 후 복사

    函数内部返回一个get函数,使用了闭包的方式,将get函数中的参数传到handlers中。

    createGetter 的逻辑:

    function createGetter(isReadonly = false, shallow = false) {
        return function get(target, key, receiver) {
            // 如果key是响应式的对象  就返回不是只读  *KinHKin注释*
            if (key === "__v_isReactive" /* ReactiveFlags.IS_REACTIVE */) {
                return !isReadonly;
            }
            // 如果key是只读对象  就返回只读是true  *KinHKin注释*
            else if (key === "__v_isReadonly" /* ReactiveFlags.IS_READONLY */) {
                return isReadonly;
            }
            // 如果key是浅层次响应对象  就返回浅层次是true  *KinHKin注释*
            else if (key === "__v_isShallow" /* ReactiveFlags.IS_SHALLOW */) {
                return shallow;
            }
            // 如果key是原始值对象并且改变的值和原始标记一致  就返回原始值  *KinHKin注释*
            else if (key === "__v_raw" /* ReactiveFlags.RAW */ &&
                receiver ===
                    (isReadonly
                        ? shallow
                            ? shallowReadonlyMap
                            : readonlyMap
                        : shallow
                            ? shallowReactiveMap
                            : reactiveMap).get(target)) {
                return target;
            }
            // 判断传入的值是不是数组
            const targetIsArray = shared.isArray(target);
            // 如果不是只读 并且是数组
            // arrayInstrumentations 是一个对象,对象内保存了若干个被特殊处理的数组方法,并以键值对的形式存储。 *KinHKin注释*
            if (!isReadonly && targetIsArray && shared.hasOwn(arrayInstrumentations, key)) {
                // 特殊处理数组返回结果 
                return Reflect.get(arrayInstrumentations, key, receiver);
            }
            // 获取 Reflect 执行的 get 默认结果
            const res = Reflect.get(target, key, receiver);
            // 如果是 key 是 Symbol,并且 key 是 Symbol 对象中的 Symbol 类型的 key
            // 或者 key 是不需要追踪的 key: __proto__,__v_isRef,__isVue
            // 直接返回 get 结果 *KinHKin注释*
            if (shared.isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
                return res;
            }
            // 不是只读对象 执行 track 收集依赖 *KinHKin注释*
            if (!isReadonly) {
                track(target, "get" /* TrackOpTypes.GET */, key);
            }
            // 是浅层次响应 直接返回 get 结果 *KinHKin注释*
            if (shallow) {
                return res;
            }
            if (isRef(res)) {
                // ref unwrapping - skip unwrap for Array + integer key.
                // 如果是 ref ,则返回解包后的值 - 当 target 是数组,key 是 int 类型时,不需要解包 *KinHKin注释*
                return targetIsArray && shared.isIntegerKey(key) ? res : res.value;
            }
            if (shared.isObject(res)) {
                // Convert returned value into a proxy as well. we do the isObject check
                // here to avoid invalid value warning. Also need to lazy access readonly
                // and reactive here to avoid circular dependency.
                // 将返回的值也转换成代理,我们在这里做 isObject 的检查以避免无效值警告。
                // 也需要在这里惰性访问只读和星影视对象,以避免循环依赖。*KinHKin注释*
                return isReadonly ? readonly(res) : reactive(res);
            }
            // 不是 object 类型则直接返回 get 结果 *KinHKin注释*
            return res;
        };
    }
    로그인 후 복사

    从这段 createGetter 逻辑中,之前专门介绍过的 ReactiveFlags 枚举在这就取得了妙用。其实目标对象中并没有这些 key,但是在 get 中Vue3 就对这些 key 做了特殊处理,当我们在对象上访问这几个特殊的枚举值时,就会返回特定意义的结果。而可以关注一下 ReactiveFlags.IS_REACTIVE 这个 key 的判断方式,为什么是只读标识的取反呢?因为当一个对象的访问能触发这个 get 陷阱时,说明这个对象必然已经是一个 Proxy 对象了,所以只要不是只读的,那么就可以认为是响应式对象了。

    get 的后续逻辑:

    继续判断 target 是否是一个数组,如果代理对象不是只读的,并且 target 是一个数组,并且访问的 key 在数组需要特殊处理的方法里,就会直接调用特殊处理的数组函数执行结果,并返回。

    arrayInstrumentations 是一个对象,对象内保存了若干个被特殊处理的数组方法,并以键值对的形式存储。

    我们之前说过 Vue2 以原型链的方式劫持了数组,而在这里也有类似地作用,下面是需要特殊处理的数组。

    • 对索引敏感的数组方法

      • includes、indexOf、lastIndexOf

    • 会改变自身长度的数组方法,需要避免 length 被依赖收集,因为这样可能会造成循环引用

      • push、pop、shift、unshift、splice

    下面的几个key是不需要被依赖收集或者是返回响应式结果的:

    __proto__
    _v_isRef
    __isVue

    在处理完数组后,我们对 target 执行 Reflect.get 方法,获得默认行为的 get 返回值。

    之后判断 当前 key 是否是 Symbol,或者是否是不需要追踪的 key,如果是的话直接返回 get 的结果 res。

    接着判断当前代理对象是否是只读对象,如果不是只读的话,则运行笔者上文提及的 tarck 处理器函数收集依赖。

    如果是 shallow 的浅层响应式,则不需要将内部的属性转换成代理,直接返回 res。

    如果 res 是一个 Ref 类型的对象,就会自动解包返回,这里就能解释官方文档中提及的 ref 在 reactive 中会自动解包的特性了。而需要注意的是,当 target 是一个数组类型,并且 key 是 int 类型时,即使用索引访问数组元素时,不会被自动解包。

    如果 res 是一个对象,就会将该对象转成响应式的 Proxy 代理对象返回,再结合我们之前分析的缓存已生成的 proxy 对象,可以知道这里的逻辑并不会重复生成相同的 res,也可以理解文档中提及的当我们访问 reactive 对象中的 key 是一个对象时,它也会自动的转换成响应式对象,而且由于在此处生成 reactive 或者 readonly 对象是一个延迟行为,不需要在第一时间就遍历 reactive 传入的对象中的所有 key,也对性能的提升是一个帮助。

    当 res 都不满足上述条件时,直接返回 res 结果。例如基础数据类型就会直接返回结果,而不做特殊处理。最后,get 陷阱的逻辑全部结束了。

    set陷阱

    set 也有一个 createSetter 的工厂函数,也是通过柯里化的方式返回一个 set 函数。

    set 的函数比较简短,所以这次一次性把写好注释的代码放上来,先看代码再讲逻辑。

    // 纯函数 默认深层次响应 函数不入参 *KinHKin*
    const set = /*#__PURE__*/ createSetter();
    // 纯函数 浅层次响应 函数入参是true *KinHKin*
    const shallowSet = /*#__PURE__*/ createSetter(true);
    function createSetter(shallow = false) {
        return function set(target, key, value, receiver) {
            let oldValue = target[key];
            // 如果原始值是只读and是ref类型and新的value属性不是ref类型  直接返回
            if (isReadonly(oldValue) && isRef(oldValue) && !isRef(value)) {
                return false;
            }
            if (!shallow) {
            // 如果新的值不是浅层次响应对象,也不是只读  更新旧值 新值为普通对象 *KinHKin*
                if (!isShallow(value) && !isReadonly(value)) {
                    oldValue = toRaw(oldValue);
                    value = toRaw(value);
                }
            // 当不是 只读 模式时,判断旧值是否是 Ref,如果是则直接更新旧值的 value
            // 因为 ref 有自己的 setter *KinHKin*
                if (!shared.isArray(target) && isRef(oldValue) && !isRef(value)) {
                    oldValue.value = value;
                    return true;
                }
            }
            // 判断 target 中是否存在 key *KinHKin*
            const hadKey = shared.isArray(target) && shared.isIntegerKey(key)
                ? Number(key) < target.length
                : shared.hasOwn(target, key);
            const result = Reflect.set(target, key, value, receiver);
            // don&#39;t trigger if target is something up in the prototype chain of original
            // 如果目标是原始对象原型链上的属性,则不会触发 trigger 派发更新  *KinHKin*
            if (target === toRaw(receiver)) {
                // 使用 trigger 派发更新,根据 hadKey 区别调用事件
                if (!hadKey) {
                    trigger(target, "add" /* TriggerOpTypes.ADD */, key, value);
                }
                else if (shared.hasChanged(value, oldValue)) {
                    trigger(target, "set" /* TriggerOpTypes.SET */, key, value, oldValue);
                }
            }
            return result;
        };
    }
    로그인 후 복사

    在 set 的过程中会首先获取新旧与旧值,当目前的代理对象不是浅层比较时,会判断旧值是否是一个 Ref,如果旧值不是数组且是一个 ref类型的对象,并且新值不是 ref 对象时,会直接修改旧值的 value。

    看到这里可能会有疑问,为什么要更新旧值的 value?如果你使用过 ref 这个 api 就会知道,每个 ref 对象的值都是放在 value 里的,而 ref 与 reactive 的实现是有区别的,ref 其实是一个 class 实例,它的 value 有自己的 set ,所以就不会在这里继续进行 set 了。

    在处理完 ref 类型的值后,会声明一个变量 hadKey,判断当前要 set 的 key 是否是对象中已有的属性。

    接下来调用 Reflect.set 获取默认行为的 set 返回值 result。

    然后会开始派发更新的过程,在派发更新前,需要保证 target 和原始的 receiver 相等,target 不能是一个原型链上的属性。

    之后开始使用 trigger 处理器函数派发更新,如果 hadKey 不存在,则是一个新增属性,通过 TriggerOpTypes.ADD 枚举来标记。这里可以看到开篇分析 Proxy 强于 Object.defineProperty 的地方,会监测到任何一个新增的 key,让响应式系统更强大。

    如果 key 是当前 target 上已经存在的属性,则比较一下新旧值,如果新旧值不一样,则代表属性被更新,通过 TriggerOpTypes.SET 来标记派发更新。

    在更新派发完后,返回 set 的结果 result,至此 set 结束。

    위 내용은 vue3에서 ref와 반응성을 사용하는 방법의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

    관련 라벨:
    원천:yisu.com
    본 웹사이트의 성명
    본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.
    인기 튜토리얼
    더>
    최신 다운로드
    더>
    웹 효과
    웹사이트 소스 코드
    웹사이트 자료
    프론트엔드 템플릿