🛤️

Rails アプリで部分的に Vue Component を使う

2022/09/16に公開

目標

app/javascript/components/path/to/hello.vue
<template>
  <p>{{message}}<p>
</template>

<script>
export default {
  props: {
    message: {
      required: true,
    },
  },
}
</script>

↑ のような Vue component があり

<%= vue "path/to/hello", {message: "Hello"} %>

のように書くと自動的にマウントされるようにする。

ヘルパー

module VueHelper
  # vue component のコンテナを作成する
  def vue(name, props, tag: :div)
    content_tag(tag, nil, "data-vue-component" => name, "data-vue-properties" => props.to_json)
  end
end

Vue 3 用

/* eslint no-console: 0 */

import { createApp } from "vue";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function mountView(el: Element, component: object, properties: any, components: any): void {
  // eslint-disable-next-line no-new
  createApp(
    Object.assign(
      {},
      component,
      components,
    ),
    properties,
  ).mount(el);
}

interface ComponentMap {
  [key: string]: object | undefined;
}

const components: ComponentMap = {};
const VueMounter = {
  mount(): void {
    const els = document.querySelectorAll("[data-vue-component]");
    for (let i = els.length - 1; i >= 0; i -= 1) {
      const el = els[i];
      const componentName = el.getAttribute("data-vue-component")!.replace(/\//gu, "-");
      const component = components[componentName];
      const propJson = el.getAttribute("data-vue-properties");
      const properties = propJson === null ? {} : JSON.parse(propJson);

      if (component === undefined) {
        throw `Vue component '${componentName} did not registered.`;
      }

      mountView(el, component, properties, {[componentName]: component});
    }
  },
  register(cmps: ComponentMap): void {
    for (const [name, component] of Object.entries(cmps)) {
      components[name] = component;
    }
  },
};

// components 以下の .vue を自動でロードして VueMounter に登録
// ./components/foo/bar.vue なら data-vue-component="foo/bar" でマウントできる
// https://vuejs.org/v2/guide/components-registration.html#Automatic-Global-Registration-of-Base-Components
interface RequireComponent {
  (fileName: string): object | {default: object};
  keys: () => string[];
}
interface NodeRequireWithContext extends NodeRequire {
  context: (a: string, b: boolean, c: RegExp) => RequireComponent;
}
const requireComponent = (require as NodeRequireWithContext).context(
  "../components", // The relative path of the components folder
  true, // Whether or not to look in subfolders
  /[a-z0-9_]+\.vue$/u, // The regular expression used to match base component filenames
);

requireComponent.keys().forEach((fileName: string) => {
  const componentConfig = requireComponent(fileName);
  const name = fileName.replace(/^\.\//u, "").replace(/\//gu, "-").replace(/\.vue$/u, "");
  // eslint-disable-next-line no-prototype-builtins
  const component = componentConfig.hasOwnProperty("default") ? (componentConfig as {default: object}).default : componentConfig as object;
  VueMounter.register({[name]: component});
});

export {VueMounter};

DOMContentLoadedVueMounter.mount() を実行する。

Vue 2 用

/* eslint no-console: 0 */

import type {VNode, VueConstructor} from "vue";
import Vue from "vue";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function mountView(el: Element, component: VueConstructor, properties: any, components: any): void {
  // eslint-disable-next-line no-new
  new Vue({
    components,
    el,
    render: (createElement): VNode => {
      return createElement(component, {props: properties});
    },
  });
}

interface ComponentMap {
  [key: string]: VueConstructor | undefined;
}

const components: ComponentMap = {};
const VueMounter = {
  mount(): void {
    const els = document.querySelectorAll("[data-vue-component]");
    for (let i = els.length - 1; i >= 0; i -= 1) {
      const el = els[i];
      const componentName = el.getAttribute("data-vue-component")!.replace(/\//gu, "-");
      const component = components[componentName];
      const propJson = el.getAttribute("data-vue-properties");
      const properties = propJson === null ? {} : JSON.parse(propJson);

      if (component === undefined) {
        throw `Vue component '${componentName} did not registered.`;
      }

      mountView(el, component, properties, {[componentName]: component});
    }
  },
  register(cmps: ComponentMap): void {
    for (const [name, component] of Object.entries(cmps)) {
      components[name] = component;
    }
  },
};

// components 以下の .vue を自動でロードして VueMounter に登録
// ./components/foo/bar.vue なら data-vue-component="foo/bar" でマウントできる
// https://vuejs.org/v2/guide/components-registration.html#Automatic-Global-Registration-of-Base-Components
interface RequireComponent {
  (fileName: string): VueConstructor | {default: VueConstructor};
  keys: () => string[];
}
interface NodeRequireWithContext extends NodeRequire {
  context: (a: string, b: boolean, c: regexp) => RequireComponent;
}
const requireComponent = (require as NodeRequireWithContext).context(
  "./components", // The relative path of the components folder
  true, // Whether or not to look in subfolders
  /[a-z0-9_]+\.vue$/u, // The regular expression used to match base component filenames
);

requireComponent.keys().forEach((fileName: string) => {
  const componentConfig = requireComponent(fileName);
  const name = fileName.replace(/^\.\//u, "").replace(/\//gu, "-").replace(/\.vue$/u, "");
  // eslint-disable-next-line no-prototype-builtins
  const component = componentConfig.hasOwnProperty("default") ? (componentConfig as {default: VueConstructor}).default : componentConfig as VueConstructor;
  VueMounter.register({[name]: component});
});

export {VueMounter};

DOMContentLoadedVueMounter.mount() を実行する。

Discussion