JSのProxy割と使われている


JavaScriptのProxyをご存知だろうか。

筆者は実際に仕事でJavaScriptを書いているが、一度も書いたことがない。

しかし、OSSのソースコードを読んでいると目にすることが多い。なぜ、Proxyが使われているのかなどの深い話は現時点ですることはできないがどのライブラリでどのような使われ方をしているのかなどの話をしていこうと思う。

初めて目にした時の話

初めて目にした時は社会人1年目にYamadaUIのOSSにコントリビュートした時である。 YamadaUIは日本発ReactのUIライブラリの1つで、多くのコンポーネントが用意されている。

Yamada UI美しく一貫性のある、アクセスしやすいコンポーネントと高度なスタイリングを実現するデザインシステムを提供し、あなたのアイデアを現実のものにします。yamada-ui.com

そのYamadaUIのfactory関数にはProxyが使われていた。 当時は「ふーんそんなのが、あるんだー」くらいの「理解する気あるんか?」と突っ込まれるような姿勢だった。

時は3年?ほど流れ、「向き合ってみようかな〜」というモチベーションになった次第である。

Proxyとは

Proxy - JavaScript | MDNProxy オブジェクトにより別なオブジェクトのプロキシーを作成することができ、そのオブジェクトの基本的な操作に介入したり再定義したりすることができます。developer.mozilla.org

とりあえず、「mdn読めばなんとかなるっしょ」ということで、

Proxy オブジェクトにより別なオブジェクトのプロキシーを作成することができ、そのオブジェクトの基本的な操作に介入したり再定義したりすることができます。

頭の硬い筆者は「うん、わからん」だったので一旦噛み砕いてみる。

そもそもプロキシーって一般的には?

英語で「Proxy」は「代理」という意味がある。

英語「proxy」の意味・使い方・読み方 | Weblio英和辞書「proxy」の意味・翻訳・日本語 - 代理(権)、委任状、代理投票、代理人|Weblio英和・和英辞書ejje.weblio.jp

つまり

「Proxy オブジェクトにより別なオブジェクトの代理を作成することができ、そのオブジェクトの基本的な操作に介入したり再定義したりすることができます。」

と書き換えられるということだ。

頑張って要約すると元のオブジェクトの代理となって、操作に処理を割り込ませたり、取得・設定するデータを加工したりできるとなる。

実装例

const foods = {
  yakkiniku: "焼肉",
  sashimi: "刺身",
  yakiton: "やきとん"
};
const handler = {
  get(target,prop) {
    return `${target[prop]}が食べたい`
  }
}

const wannerEatASpecificFood = new Proxy(foods, handler)

console.log(wannerEatASpecificFood.yakiton);
console.log(wannerEatASpecificFood.yakkiniku);
console.log(wannerEatASpecificFood.sashimi);

wannerEatASpecificFood がfoodsの代理となり、プロパティへのアクセスに割り込んで、返却するデータを加工している。

Proxyのハンドラー関数

Proxyは第二引数に指定するハンドラーオブジェクトに、ハンドラー関数を定義できる。 ハンドラー関数は、プロパティの参照や関数の呼び出しなど、対応する操作が行われたときに発火する関数である。

ハンドラー関数の種類はいくつかあるのでmdnを見てもらえると良い。

Proxy() コンストラクター - JavaScript | MDNProxy() コンストラクターは Proxy オブジェクトを生成します。developer.mozilla.org

Proxyが使われている事例

ここからProxyが使われているライブラリを紹介する。

YamadaUI

冒頭でも書いたがYamadaUIのfactory関数にはProxyが使われている。 この関数はstyledというAPIとして配布されている。

このstyledという名前はv2用でv1ではuiという名前でAPIが提供されていた。

レガシー(v1)ドキュメントにこのAPIの使い方が書いてある。

https://v1.yamada-ui.com/ja/styled-system/uiv1.yamada-ui.com

ここで面白い部分が、1つのAPIでプロパティアクセスと関数呼び出しができることだ。

<ui.button
  py="sm"
  px="md"
  rounded="md"
  bg="purple.600"
  color="white"
  _hover={{ bg: "purple.500" }}
>
  Click me!
</ui.button>
import { ui } from "@yamada-ui/core"

const Button = ui("button")

const Demo = () => {
  return <Button>Click me!</Button>
}

使い分けとしては、 ui.buttonは、その場でスタイルを指定してUIを作りたい場合に使う、 ui("button")は、共通のスタイルや振る舞いを持つコンポーネントを定義し、再利用したい場合に使う。

さらに関数宣言の方は独自のコンポーネントを引数として渡すことができ、そのコンポーネントにYamadaUIのスタイルシステムを与えることができる。

import { ui } from "@yamada-ui/core"
import { YourComponent } from "./your-component"

const NewComponent = ui(YourComponent)

const Demo = () => {
  return (
    <NewComponent
      py="sm"
      px="md"
      rounded="md"
      bg="purple.600"
      color="white"
      _hover={{ bg: "purple.500" }}
    >
      Click me!
    </NewComponent>
  )
}

1つのAPIで2つの使い方、表現を実現させるためにProxyを使っている(と勝手に予想している)。

実際の中身の実装は以下のようになっている。

function factory() {
  const cache = new Map<DOMElement, FC>()
  const target: ProxyTarget = (el, options) => createStyled(el, options)

  return new Proxy(target, {
    apply: function (
      _target,
      _thisArg,
      [el, options]: [DOMElement, StyledOptions],
    ) {
      return createStyled(el, options)
    },

    get: function (_target, el: DOMElement): FC | undefined {
      if (!cache.has(el)) cache.set(el, createStyled(el))

      return cache.get(el)
    },
  }) as Factory
}

/**
 * `styled` is an object of JSX elements enabled with Yamada UI's style system,
 * and can also be used as a function for custom components to receive Yamada UI's style system.
 *
 * @see https://yamada-ui.com/docs/components/styled
 */
export const styled = factory()

yamada-ui/packages/react/src/core/system/factory.ts at main · yamada-ui/yamada-uiReact UI components of the Yamada, by the Yamada, for the Yamada built with React and Emotion. - yamada-ui/yamada-uigithub.com

ハンドラー関数ではgetとapplyが宣言されている。 getはプロパティにアクセスされた時、applyは関数呼び出しされた時に発火する。

handler.get() - JavaScript | MDNhandler.get() は、オブジェクトの [[Get]] 内部メソッドに対するトラップです。プロパティアクセサーなどの操作で使用されます。developer.mozilla.org

handler.apply() - JavaScript | MDNhandler.apply() メソッドは、オブジェクトの [[Call]] 内部メソッドに対するトラップです。関数呼び出しなどの操作で使用されます。developer.mozilla.org

applyではそのままスタイルシステムを付与する関数を発火させている。 一方、getでは、Mapをキャッシュとして利用している。指定されたプロパティ(HTMLタグ)に対応するコンポーネントがキャッシュに存在しない場合は生成してキャッシュに保存し、最終的にそのコンポーネントを返している。

このAPIでは、v1は基本スタイルをbaseStyleで指定していたが、v2ではbaseに名称が変わり、variantsなども与えられるようになった。

以下ドキュメント引用のソース。

const Button = styled("button", {
  base: {
    alignItems: "center",
    appearance: "none",
    cursor: "pointer",
    display: "inline-flex",
    fontWeight: "medium",
    justifyContent: "center",
    overflow: "hidden",
    position: "relative",
    rounded: "l2",
    transitionDuration: "moderate",
    transitionProperty: "common",
    userSelect: "none",
    verticalAlign: "middle",
    whiteSpace: "nowrap",
    _readOnly: { layerStyle: "readOnly" },
    _disabled: { layerStyle: "disabled" },
  },
  variants: {
    outline: {
      layerStyle: "outline",
      _hover: { layerStyle: "outline.hover" },
    },
    solid: {
      layerStyle: "solid",
      _hover: { layerStyle: "solid.hover" },
    },
    subtle: {
      layerStyle: "subtle",
      _hover: { layerStyle: "subtle.hover" },
    },
  },
})

マイグレーション - Yamada UIv1.xからv2.xの新しい機能と改善点。yamada-ui.com

chakraUI

YamadaUIに似たライブラリとしてchakraUIがある。

Chakra UISimple, Modular & Accessible UI Components for your React Applicationschakra-ui.com

chakraというAPIが存在し、かなりYamadaUIのstyledに似たものになっている。

Chakra Factory | Chakra UIUse the chakra factory to create supercharged componentschakra-ui.com

const chakraImpl = new Proxy(styledFn, {
  apply(_, __, args) {
    // @ts-ignore
    return styledFn(...args)
  },
  get(_, el) {
    if (!cache.has(el)) {
      cache.set(el, styledFn(el as any))
    }
    return cache.get(el)
  },
})

export const chakra = chakraImpl as unknown as StyledFactoryFn

chakra-ui/packages/react/src/styled-system/factory.tsx at main · chakra-ui/chakra-uiChakra UI is a component system for building SaaS products with speed ⚡️ - chakra-ui/chakra-uigithub.com

YamadaUIとほとんど同じなのでここでの詳細は割愛する。

motion

アニメーションライブラリのmotionである。

Motion (prev Framer Motion): JavaScript & React animation libraryMotion (prev Framer Motion) is a fast, production-grade animation library for React, JavaScript and Vue. Build smooth UI animations at a tiny footprint.motion.dev

このライブラリは元々 Framer Motion というReact向けのアニメーションライブラリだった。作者のMatt PerryがFramer社を退職するタイミングで独立したプロジェクトとなり、Motion へと名前を変更した。また、ReactだけでなくWeb全体で利用できるアニメーションライブラリを目指し、Vanilla JavaScript向けのAPIやVue向けのライブラリも提供され、現在に至っている。

Framer Motion is now independent, introducing Motion | Motion MagazineFramer Motion is now independent. Introducing Motion, a new animation library for React and all JavaScript environments. Here&#x27;s what it means for you.motion.dev

使い方としては、Reactの場合、

import { motion } from "motion/react"
  
function Component() {
  return <motion.button animate={{ opacity: 1 }} />
}

Motion for React: Get started - React Animation Library | Motion for ReactInstall Motion for React, animate elements with spring animations. Complete guide with examples.motion.dev

Vueの場合、

<motion.button :animate="{ opacity: 1 }" />

Get started with Vue animations | Motion for VueLearn Motion for Vue animation library: Install, animate HTML and SVG elements with spring animations, staggering effects. Complete guide with examples.motion.dev

VanillaJSの場合

Get started with Motion | install, first animation | MotionLearn Motion animation library: Install, animate HTML/SVG/WebGL elements with spring animations, staggering effects. Complete guide with examples.motion.dev

<div class="box"></div>

<script type="module">
    import { animate } from "motion"

    animate(".box", { rotate: 360 }, { duration: 1 })
</script>

<style>
    .box {
        width: 100px;
        height: 100px;
        background-color: var(--hue-3);
        border-radius: 10px;
    }
</style>

と書くことができる。

本体のソースコードpackages/framer-motion/src/render/components/create-proxy.tsのcreateMotionProxyにて、Proxyが使われている。

export function createMotionProxy(
    preloadedFeatures?: FeaturePackages,
    createVisualElement?: CreateVisualElement<any, any>
): MotionProxy {
    if (typeof Proxy === "undefined") {
        return createMotionComponent as MotionProxy
    }

    /**
     * A cache of generated `motion` components, e.g `motion.div`, `motion.input` etc.
     * Rather than generating them anew every render.
     */
    const componentCache = new Map<string, any>()

    const factory = (Component: string, options?: MotionComponentOptions) => {
        return createMotionComponent(
            Component,
            options,
            preloadedFeatures,
            createVisualElement
        )
    }

    /**
     * Support for deprecated`motion(Component)` pattern
     */
    const deprecatedFactoryFunction = (
        Component: string,
        options?: MotionComponentOptions
    ) => {
        if (process.env.NODE_ENV !== "production") {
            warnOnce(
                false,
                "motion() is deprecated. Use motion.create() instead."
            )
        }
        return factory(Component, options)
    }

    return new Proxy(deprecatedFactoryFunction, {
        /**
         * Called when `motion` is referenced with a prop: `motion.div`, `motion.input` etc.
         * The prop name is passed through as `key` and we can use that to generate a `motion`
         * DOM component with that name.
         */
        get: (_target, key: string) => {
            if (key === "create") return factory

            /**
             * If this element doesn't exist in the component cache, create it and cache.
             */
            if (!componentCache.has(key)) {
                componentCache.set(
                    key,
                    createMotionComponent(
                        key,
                        undefined,
                        preloadedFeatures,
                        createVisualElement
                    )
                )
            }

            return componentCache.get(key)!
        },
    }) as MotionProxy
}

motion/packages/framer-motion/src/render/components/create-proxy.ts at main · motiondivision/motionA modern animation library for React and JavaScript - motiondivision/motiongithub.com

上記はReact版のmotionの実装である。 ほぼ、YamadaUIやchakraUIと同じようなケースである。

Mapをキャッシュとして利用することで、不要なコンポーネントの再生成を防いでいる。これはパフォーマンス最適化の1つと言って良いだろう。

Vueのmotionの実装も見てみる。

export function createMotionComponentWithFeatures(
  featureBundle?: FeatureBundle,
) {
  return new Proxy({} as unknown as MotionNameSpace, {
    get(_, prop) {
      if (prop === 'create') {
        return (component: any, options?: MotionCreateOptions) =>
          createMotionComponent(component, {
            ...options,
            ...featureBundle,
          })
      }

      return createMotionComponent(prop as string, {
        ...featureBundle,
      })
    },
  })
}

React版と少し違うが、やっていることはほぼ同じだと見ている。 Vue版ではキャッシュ関連の処理がcreateMotionComponent内にあり、createとその他で一見同じ関数を利用しているが、渡された値がHTMLタグを表す文字列かVueコンポーネントかによって内部の処理が分かれている。

コード内のコメントには

/**
 * Creates a motion component from a base component or HTML tag
 * Caches string-based components for reuse
 */

と書かれている。React版と同様に、一度生成したHTMLタグのMotionコンポーネントをキャッシュして再利用することで、不要なコンポーネントの再生成を防いでいる。

Vue.js

Vue.jsでもProxyは使われていた。 reactiveだ。

Vue.jsVue.js - The Progressive JavaScript Frameworkja.vuejs.org

リアクティビティーの一つである。リアクティビティーは

リアクティビティーとは、宣言的な方法で変化に対応できるようにするプログラミングパラダイムです。

とドキュメントに書いてある。

Vue.jsVue.js - The Progressive JavaScript Frameworkja.vuejs.org

ドキュメントにはExcelのことが書いてあり、「A2セルに=A0+A1と定義しておいたら、A0やA1を変更したらA2も変更されるよね〜」的なことが書いてありこのような事象がリアクティビティーである。

「Vue.jsでは状態を変更したらUIもそれに応じて変更される」と筆者は理解している。

Proxyに関して、Vue.jsのドキュメントにも使っていると書いてあった。

リアクティブオブジェクトは JavaScript プロキシ であり、通常のオブジェクトと同じように動作します。違いは、Vue がリアクティブオブジェクトのすべてのプロパティのアクセスや変更をインターセプトして、リアクティビティーの追跡やトリガーを行うことができることです。

function createReactiveObject(
  target: Target,
  isReadonly: boolean,
  baseHandlers: ProxyHandler<any>,
  collectionHandlers: ProxyHandler<any>,
  proxyMap: WeakMap<Target, any>,
) {
  //なんかもろもろ処理が書いてある

  const proxy = new Proxy(
    target,
    targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers,
  )
  proxyMap.set(target, proxy)
  return proxy
}

core/packages/reactivity/src/reactive.ts at main · vuejs/core🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web. - vuejs/coregithub.com

ハンドラー関数も少し見てみる。


class MutableReactiveHandler extends BaseReactiveHandler {
  constructor(isShallow = false) {
    super(false, isShallow)
  }

  set(
    target: Record<string | symbol, unknown>,
    key: string | symbol,
    value: unknown,
    receiver: object,
  ): boolean {
    //これ以降にたくさんゴニョゴニョ書いているよぉ〜

    // UI更新などのリアクティブな処理を発火する入り口
    if (target === toRaw(receiver) && result) {
      if (!hadKey) {
        trigger(target, TriggerOpTypes.ADD, key, value)
      } else if (hasChanged(value, oldValue)) {
        trigger(target, TriggerOpTypes.SET, key, value, oldValue)
      }
    }
    return result
  }

  //なんか書いてある
}

core/packages/reactivity/src/baseHandlers.ts at main · vuejs/core🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web. - vuejs/coregithub.com

このsetの部分はオブジェクトに代入するとき

state.count++やstate.hoge = "hello"とした時に発火する関数である。

getについては、

class BaseReactiveHandler implements ProxyHandler<Target> {
  constructor(
    protected readonly _isReadonly = false,
    protected readonly _isShallow = false,
  ) {}

  get(target: Target, key: string | symbol, receiver: object): any {
    if (key === ReactiveFlags.SKIP) return target[ReactiveFlags.SKIP]

    //なんか色々やっているよ〜

    return res
  }
}

core/packages/reactivity/src/baseHandlers.ts at main · vuejs/core🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web. - vuejs/coregithub.com

別のクラスに定義してあった。

state.count = 1のように、通常のオブジェクトと同じ記法で値を変更するだけで、その変更を検知してUIに反映できる。この仕組みは、オブジェクトへの操作に介入できるProxyだからこそ実現しやすいものだと考えている。

Vue.js本体のコードは大きいので、chibivueというVue.jsの中身を学ぶ上でとても良い教材がある。そちらを読んでみるのありかと。

chibivueWriting Vue.js: Step by Step, from just one line of "Hello, World".book.chibivue.land

リアクティビティーについても以下に記載してある。

chibivueWriting Vue.js: Step by Step, from just one line of "Hello, World".book.chibivue.land

わかりやすく書いているのでおすすめである。

まとめ

  • Proxyは元のオブジェクトの代理としてオブジェクトを操作するなどのことをするもの
  • UIライブラリやVue.jsのリアクティブシステムの一部に使われている
  • chibivueは良い教材でオヌヌメ