ウエブサイトにチュートリアルを追加しましょう!

2024/02/24に公開

書き始めるきっかけ

周知の事実だと思いますが、ウェブサイトの機能などが複雑になるに連れて、チュートリアル、もしくはサイトツアーがどんどん不可欠になってきます。

例えばGCPやAWSなどのSASS系のサイトでは、チュートリアルなしでは、ボタンが多すぎて目が回りますね

筆者も最近大型サイトのチュートリアルを書かざる負えなくなったので、色々の現存パッケージを調査してみたのですが、結果として自分で開発することになりました(w

なぜ自分で開発するかを選んだのかについては、理由は後ほど紹介します。なのでこの記事がそれらのパッケージを比較しながら自分が開発したライブラリーを紹介していきます。

現存パッケージの比較

まずライブラリーに求めている機能をまとめると

  • サイト自体が筆者が開発したものではないため、1個1個ハイライトするtarget elementを探してIDやclassなどを付けるのができるだけ避けたい。
  • レンダリングプロセスをコントロールしたい。例えば自分のコンポーネントをレンダリングできるとか。一番良いのが既存コンポーネントのコードをそのまま流用できるのが一番良い。styleだけoverrideできるのがギリギリ許容範囲内ではあるが、出来だけ避けたい。
  • チュートリアルが複数ページを跨ぐものなので、条件つきレンダリングが必要。そしてnoticeを出すためにcallbackなどの機能も欲しい。欲を言えばこの条件検査は現存コードと分離して書けると尚良し。
  • 以上の必要機能を踏まえて、現代的なAPIがあれば加点とします、かつメインロジックと分離して書けるとさらに良し

そして、筆者はReactプロジェクトでこの問題遭遇したため、Reactで使えるライブラリーだけ調査してきました。もしかしたら他のフレームワークで解決策があったかもしれないが、そこらへんはご容赦ください。

まずはgithub上でstarの一番多い intro.js ですが
https://github.com/usablica/intro.js
Reactで利用するのはAPIが古く、あとMITライセンスじゃないのも痛いですね。そして現存コードに侵入してdata-introdata-stepを設定しないと使えないのでパスしました。

二番手に来るのが筆者が調べている中で2番目に星が多い react-joyride です
https://github.com/gilbarbara/react-joyride
すごく良くできていて、カスタムコンポーネントも使えて、条件付きレンダリングも対応しています。悪いのが条件判断が既存コードに組み込まないといけない、かつstate管理しないといけないのがちょっとめんどくさい。あともclassnameなどのセレクター以外の方法を提供していないのもちょっと痛い

3番目は reactour です
https://github.com/elrumordelaluz/reactour
react-joyrideとほぼ同じ機能かつ同じ悪さですが、react-joyrideより簡単かつ現代的なAPIを提供しているので、もし使うなら筆者はこっちの方が使いたいかな

最後に react-shepherd ですが
https://github.com/shepherd-pro/react-shepherd
このライブラリーはshepherdをreactでラッピングしたものなので、APIはreactぽいじゃない、かつ設定が凡雑なのでまたもやパス。

最終的に言うと、reactで使うのであればreact-joyrideもしくはreactour現在の最適解ではある(かもしれない)ので、使って書いてみたのですが、やはりコードを書き始めると色々な状況に遭遇して、そして一番筆者を苦しんだのがtarget elementを探してidを付けることでした...

ページ設計の初期段階からチュートリアルも書いていたらまたいいものの、サイトが既に完成した状態でチュートリアルを上乗せの感覚で書くと、この2度手間は流石になんとか省きたい。そして普通にstate管理がめんどくさいので、後からのメンテナンスも、コードを分割してないとややこしいと言う観点からもあまり良くないので、かつ一人やって1時間で完成する作業を2時間掛けて自動化するのがエンジニアなので、自分で作ることしました

完成したものはこちらです
https://github.com/March-mitsuki/easy-tutorial-react

自分の欲しい機能を丸ごと積み込んでましたので紹介します

easy-tutorial-react の紹介

まずメリットから

  • 完全に現存コードと分割して書ける(どうしてもの場合は現存コードのアクションが必要な場合は最小限のコードだけ現存コードに組み込み、IDEの検索機能、例えば VSCode の Find All References などで素早くそのコードの位置を探せる)
  • 宣言的なAPI
  • 完全なレンダリングプロセスのコントロール(デフォルトでも簡単な light mode / dark mode 対応したコンポーネントをexportしている)

XPath で Element を探す

最小限の例から見ていきましょう

import {
  EasyTutorial,
  EasyTutorialRenderer,
  EasyTutorialNoticeRenderer,
} from "easy-tutorial-react";

const easyTutorial = new EasyTutorial();

const intro = easyTutorial.addTutorial("intro")
introduction.addStep({
   targetQuery: "#page-title",
   content: <div>Hello from easy-tutorial-react, you can click next button to go next.</div>
})
introduction.addStep({
   targetQuery: "#page-contents",
   contents: <div>Step 2 here.</div>,
   placement: "bottom-center",
})

export default function App() {
   return (
      <>
         <EasyTutorialRenderer dataSource={easyTutorial} />
         <EasyTutorialNoticeRenderer dataSource={easyTutorial} />

         <main>
            <button onClick={() => easyTutorial.start("intro")}>
               Start Tutorial
            </button>

            <h1 id="page-title">Page Title</h1>
            <p id="page-contents">Page contents</p>
         </main>
      </>
   )
}

この例ではtutorialのコードとメインコードを一緒に書いているのですが、Best Practiceのときは別ファイルにするのをおすすめします。

そして一見にして、「オメェもclassなど使ってるじゃねぇか」とつっこむ方がいると思いますが、待ってください。確かにquerySelectorを使って探すのもサポートしていますが、XPathもサポートしています。

なぜ例の中でXPathを使わないかと言うと、reactのコードを見るだけではdom構造がわからないからです。そして、後ほど述べますが、reactはdom構造をレンダリング中で動的に変更するから、idとXPathの併用をお勧めいたします。

まずなぜXPathから述べますが、emotionなどのライブラリーを使っているプロジェクトなら、そもそも確定したclassNameが存在しないのが一つで、XPathも直接ブラウザーのF12(デベロッパーツール)からコピーできるもので、これが一番直感的で、このコンポーネントがコード中のどこにあるのかを知らなくても、確実にこのElementを探せるものです。

当然、前述したreactはdom構造をレンダリング中で動的に変更する問題も依然として存在していますので、なのでidとXPathを併用したものをお勧めします。でも1個1個target elementに付けなくてオッケーです。なぜなら、現代のブラウザーは賢いです。右クリックして、Copy XPathを押すと、自動的に一番近いidのあるelementから選択したものへにXPathを計算してくれます。

つまり いくつか大きいコンポーネントにidを付ければ、残りのものは全部F12を押してXPathをコピーして行けば 簡単にtutorialを書けることになります。idから指定のelementまでの相対的なdomが更新する時に変化がなければ、reactでもXPathを使えます。

実際に使うとき、セクションごと、例えばUserPageなどページごとにIDをつけるとかのがお勧めです。実際は各々のプロジェクトによって違うと思いますが、1個1個idやclassを振り分けるよりは大分の手間が省けると思います。

レンダリングプロセスをコントロール

out of boxの使い方もできて、深くカスタマイズしたい場合でも多くのものを勉強せず、直感的に書けるのが一番良いと思ってこのAPIを設計していますが、また改善の余地があるとも思っているので、もし感想があったらぜひコメントやissueでください。

そして水面下では、実はreact hooksと完全分離した形でrendererを実現しているので、他のフレイムワークにも移植しやすいと思います。(さらに言うとmittを使ったイベント駆動の形で設計しています。なので現存コードと完全分離に書けると言う)

条件つきレンダリング、カスタムコンポーネント、NoticeAPIは全部ここに含んでいるので、具体的な例が見たい場合はgithubページをみることをお勧めします。ここではAPIのアーキテクチャだけ説明することにする。

render processは主にaddStepと言うmethodにフォーカスして展開しています。具体的な定義はこのようになります。

長いので一旦スキップして、これからの説明を読んだ上だと見やすいかも。

type TutorialStep<A extends Array<any>> = {
  targetQuery: string;
  content: JSX.Element;
  render: RenderFunc<A>;
  noticeMsg: string;
  noticeTitle: string;
  backNoticeMsg: string;
  backNoticeTitle: string;
  noticeDuration: number;
  scrollInView: boolean;
  placement: Placement;
  canRender: () => boolean;
};
type AddStepParams<A extends Array<any>> = Partial<
  Omit<TutorialStep<A>, "targetQuery" | "content">
> & {
  targetQuery: string;
  content?: JSX.Element;
};

その中で一番重要なのがrenderと言うパラメータです、同じく一旦型定義を貼ります。

export type RenderFuncBasicArg = {
  targetElem: Element;
  stepType: StepType;
  next: () => void;
  prev: () => void;
  stop: () => void;
  placement: Placement;
  totalStep: number;
  currentStep: number;
  currentContent: JSX.Element;
};
export type RenderFunc<A extends Array<any>> = (
  basicArg: RenderFuncBasicArg,
  ...args: A
) => JSX.Element;

長いですが、注目すべきはaddStep.canRenderRenderFuncBasicArg.nextだけです。

一言で言うと、毎回next()がコールされるたびに、canRender()が呼ばれて、戻り値がTrueならばrender()をコールします。Falseなら"canNotRender"イベントを発生。

つまりレンダリングをコントロールしたい場合、renderバラメータを独自なものにすれば全部手の中のものと言うとこ、そして例外処理したい場合easyTutorial.on("canNotRender", () => {})するだけで処理できます。renderをオーバーライドする時も<button onClick={next}>Next</button>で、見慣れた書き方で元の機能を保持できます。

条件つきレンダリング、カスタムコンポーネント、NoticeAPI、3つのことですが上の一句だけ理解できれば

このAPIは従来のAPIより良い点としては、状態管理は全て内部でやっている点。ユーザーは見た目だけ重心を置ける。そしてoverrideする時も、新たなAPIを勉強して、色々なpropsを渡すのではなく、直接今までのreact知識だけあれば書けると言う点にあるかと思います。

コードの例はgithubに載っているので、ここでコードを書くよりgithubを見た方が早いかもしれませんので割愛です。

さらに話すとコードのアーキテクチャ説明になってしまうのでここで締め切ろうかと思います。

補足

条件つきレンダリングについてですが、「addStepのときだけcanRenderを定義できるのって足りる?」、「コンポーネントないのstateを参照にcanRenderのreturn値を変えたいのですが...」と言う質問が出てくるかもしてないと思いますが、自分の観点から見ると、Tutorialはユーザーに見られてからのもので、もし現状は隠れたstateを依存に判断しているのであれば、それをユーザーがわかるようにするのが一番です。

例えばアップロードの例だと、完了した時にアップロード完了の文字を出して、canRenderはこの文字が探せたらtrueを返すのがbest practiceと思います。
さらに分かりやすい例を言えば、録音中にRECの文字が出るのが当たり前のように、ユーザー感知のあるもの、特に何をして何が変わったら次へ進めるのが、ユーザーに知らせた方が一番と思いますので、このようなAPIにしてます。

隠れたstateを条件としての判断はユーザーフレンドリーではないし、ユーザーが困惑してしまうかもしれないのでお勧めしません。

最後に

長長く話なしたが、結局どのライブラリーを使うのかは状況によってかと思います。自分はだた現有のライブラリーが自分おユースケースに合わなかったため自作しただけです。みなさんは自分に合ったものを選んだ使いましょう。ウエブサイトのチュートリアルを作る時の選択肢が一個増えたと考えたいただけたらもう幸いです。

Discussion