Madogiwa Blog

主に技術系の学習メモに使っていきます。

Vue.js: 親コンポーネントの文字列を置換して動的に子コンポーネントをレンダリングするMEMO

テキスト内のリンクを検出して自動的にリンク用のコンポーネントレンダリングするみたいなことをするときにどうするのが良いのかよくわかってなかったので調べて、ちょっと実装してみた内容をメモしておきます📝(もっといいいやり方があるかも知れないです💦)

v-htmlを使用していているのでXSSには十分に注意してください。

実装サンプルはComposition APIを使って記載しているので、そのあたりの内容は下記を参照してください。

madogiwa0124.hatenablog.com

ケース: テキスト内の特定の文字列をリンク用の子コンポーネントに変換する

今回は下記のようなpropsでテキストを渡してあげるとリンクnの形式の文字列が含まれていたら、<a href="/">リンクn</a>に変換してくれるようなコンポーネントを考えてみます。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>home</title>
  </head>
  <body>
    <div id="vue-root">
      <linked-text initialize-text="これがリンク1だよ。これもリンク2だよ"/>
    </div>
  </body>
</html>

リンク用のコンポーネントは下記のような感じになっています。

<template>
  <a href="/">{{ message }}</a>
</template>
<script lang="ts">
import Vue from "vue";
import VueCompositionApi, { defineComponent } from "@vue/composition-api";
Vue.use(VueCompositionApi);

type Props = { initializeMessage: string };

export default defineComponent({
  name: "Link",
  props: {
    initializeMessage: {
      type: String,
      default: "",
    },
  },
  setup(props: Props) {
    const message = props.initializeMessage;
    return {
      message,
    };
  },
});
</script>
<style></style>

これがリンク1だよ。これもリンク2だよを渡したときの表示イメージはこんな感じです🎨

f:id:madogiwa0124:20200825161038p:plain

対応方法MEMO

色々調べて方針としては下記のような感じで考えてみました。

  • テキストの表示部分はv-htmlを指定
  • テキスト内の特定の文字列(リンクn)を変換しやすいようにタグの文字列(<span class="link">リンクn</span>)に置換
  • マウント後、生成したタグを持つ要素を取得して子コンポーネントに置換

実際に実装してみたのが以下です。

<template>
  <div class="link-list">
    <p v-html="text" />
  </div>
</template>
<script lang="ts">
import Vue from "vue";
import VueCompositionApi, { defineComponent, onMounted } from "@vue/composition-api";
import Link from "@js/components/Link.vue";
Vue.use(VueCompositionApi);

const LINK_TAG_STR = ["<span class='link'>", "</span>"];
const LINK_TAG_SELECTOR = "span.link";
const LINK_STR_REGXP = /リンク./g;

function buildChildComponent(props: object) {
  // Vue.extendで対象コンポーネントのコンストラクタを取得
  // https://012-jp.vuejs.org/guide/components.html
  const LinkComponent = Vue.extend(Link);
  // 子要素のinstanceを生成して返す
  return new LinkComponent({ propsData: props });
}

function sanitize(text: string) {
  // 本来ならXSSが発生しないように適切にsanitizeする必要があるが割愛
  return text;
}

function replaceLinkTag(text: string) {
  // 特定の文字列をタグの文字列に変換
  return text.replace(LINK_STR_REGXP, `${LINK_TAG_STR[0]}$&${LINK_TAG_STR[1]}`);
}

export default defineComponent({
  name: "LinkList",
  components: { Link },
  props: {
    initializeText: {
      type: String,
      required: true,
    },
  },
  setup(props: { initializeText: string }) {
    // サニタイズしつつ置換対象の特定の文字列をタグに変換
    const text = replaceLinkTag(sanitize(props.initializeText));
    onMounted(() => {
      document.querySelectorAll(LINK_TAG_SELECTOR).forEach((link) => {
        // Componentからインスタンスを生成
        const instance = buildChildComponent({ initializeMessage: link.textContent });
        instance.$mount(); // インスタンスをマウント
        // 対象のnodeをLinkコンポーネントで置換
        if (link.parentNode) link.parentNode.replaceChild(instance.$el, link);
      });
    });
    return {
      text,
    };
  },
});
</script>
<style></style>

ポイントは、onMountedの中でVue.extend(CompnentClass)で対象のコンポーネント用のコンストラクタを取得してComponentのインスタンスを生成してあげて、それをinstance.$mount()後にinstance.$elで要素として取得し生成したタグと置換してあげているところかなと思います💦

function buildChildComponent(props: object) {
  // Vue.extendで対象コンポーネントのコンストラクタを取得
  // https://012-jp.vuejs.org/guide/components.html
  const LinkComponent = Vue.extend(Link);
  // 子要素のinstanceを生成して返す
  return new LinkComponent({ propsData: props });
}

リンクなど動的にコンポーネントに置換したいようなケースは、そこそこありそうなのでXSSには注意しながらいい感じに実装していきたいですね、それでは👋

参考資料

012-jp.vuejs.org

ryotah.hatenablog.com

qiita.com