petitviolet_blog

@petitviolet blog

ReactからAceEditorを使ってリッチなエディタを表示する

これはなに

リッチなエディタとしてajaxorg/aceというものがあり、Reactから使おうとするとsecuringsincity/react-aceが便利。

react-aceが提供するコンポーネントをラップするような独自のコンポーネントを実装する。

具体的には、以下の三点。

  • ファイルタイプに応じて動的にrequireする
  • React hooksを使う
  • readonlyモードを用意する

実装

大した量でもないので実装を貼り付ける

import * as React from 'react';
import AceEditor from 'react-ace';

// AceEditor周りで使うデータ
export type EditorProps = {
  fileType: string;
  contents: string;
  readOnly: boolean;
  onChange?: (content: string) => void;
  theme?: string;
};

const MIN_LINE = 10;
const MAX_LINE = 50;

const DEFAULT_THEME = 'monokai';

export const EditorComponent = (props: EditorProps) => {
  const theme = props.theme || DEFAULT_THEME;

  // load theme
  React.useMemo(() => {
    try {
      require(`ace-builds/src-noconflict/theme-${theme}`);
    } catch (e) {
      console.log(`error new theme(${theme}): ${e}`);
    }
  }, [props.theme]);

  // load mode
  React.useMemo(() => {
    if (props.fileType == null) {
      return;
    }
    try {
      require(`ace-builds/src-noconflict/mode-${props.fileType}`);
    } catch (e) {
      console.log(`error new mode(${props.fileType}): ${e}`);
    }
  }, [props.fileType]);

  const onChange: (string) => void = props.onChange ? props.onChange : () => {};

  if (props.readOnly) {
    return (
      <AceEditor
        mode={props.fileType}
        theme={theme}
        value={props.contents}
        width={null} // nullにしておくとwidthを外から変えられる
        minLines={MIN_LINE}
        maxLines={MAX_LINE}
        readOnly={true}
        focus={false}
        highlightActiveLine={false}
        enableBasicAutocompletion={false}
        onLoad={editor => {
          // readOnlyでも表示されるカーソルを消す
          editor.renderer.$cursorLayer.element.style.opacity = 0;
        }}
      />
    );
  } else {
    return (
      <AceEditor
        mode={props.fileType}
        theme={theme}
        value={props.contents}
        width={null}
        minLines={MIN_LINE}
        maxLines={MAX_LINE}
        readOnly={props.readOnly}
        enableBasicAutocompletion={true}
        onChange={onChange}
      />
    );
  }
};

動的なtheme, modeの読み込み

aceで用意されているthemeやmodeはここから見ることが出来る。 https://github.com/ajaxorg/ace-builds/tree/master/src-min-noconflict

本当にこれでいいのかはよくわからんが、ace-builds/src-noconflict/配下をrequireすることで動的に読み込み出来ている様子。

そしてtheme, modeの読み込みにはReact.useMemoを使用しているが、初期化が遅いとかではなく副作用を起こすようなものなのだからReact.useEffectの方が適切では?と思うかもしれないが、実行タイミングが異なり、useMemoは同期的だがuseEffectコンポーネントの初期化が終わったあとに実行される。
ドキュメントにもあるように従来のcomponentDidMountと同じに思えばよい。 そのためAceEditorコンポーネントレンダリングする前にthemeやmodeは読み込み終わっていてほしいため、useEffectだとタイミングが遅すぎるため、useMemo(あるいはuseContext)の方が適切となる。

readonlyモード

readOnlytrueを与えればreadonlyにはなるが、それは単に更新は出来ないだけでカーソルはフォーカス出来てしまう。 そこでonLoadでstyleを弄って透明にしてしまうことで見た目上はフォーカスされていないように見せかけることで対応。

readOnly={true}
focus={false}
highlightActiveLine={false}
onLoad={editor => {
  // readOnlyでも表示されるカーソルを消す
  editor.renderer.$cursorLayer.element.style.opacity = 0;
}}

随分古いissueのコメントを参考にした。 Option to make editor disabled · Issue #266 · ajaxorg/ace

react-routerで同じComponentにLinkで遷移したときにstateがリフレッシュされない

まとめ

Routeに異なるkeyを設定する

<Route exact key={'new'} path={'/posts/new'} component={EditPostComponent} />
<Route exact key={'edit'} path={'/posts/:id/edit'} component={EditPostComponent} />

<li>などでリストを表示したときにkeyを設定してないとwarningが表示されるが、それと同じことが起きていると思えばよい。 https://reactjs.org/docs/lists-and-keys.html#keys

サンプル

コードを貼り付けるとこんな感じ。

import * as React from 'react';
import {Link, Route, Router, Switch, useLocation} from 'react-router-dom';

const MyComponent = (props) => {
  const current = useLocation().pathname;
  const next = current == '/a' ? '/b' : '/a';
  const [counter, setCounter] = React.useState<number>(0);
  return (
    <>
      <div>
        <div>Counter: {counter}</div>
        <button onClick={() => setCounter(counter + 1)}>Increment</button>
      </div>
      <Link to={next}>Current: {current}, Next: {next}</Link>
    </>
  );
};

const App: React.FC = () => {
  const history = require('history').createBrowserHistory;
  // 同じMyComponentを表示する異なるパスがある
  return (
    <Router history={history()}>
      <Switch>
        <Route exact path={'/'} render={() => <Link to={'/a'}>To /a</Link>} />     
        <Route exact path={'/a'} component={MyComponent} />
        <Route exact path={'/b'} component={MyComponent} />
      </Switch>
    </Router>
  );
};

動かしてみると、パスが変わってもstateが保持されてしまっているのがわかる。 CodePenのとはimportしてたりわずかに違うが、コードとしては同じ。

See the Pen React Router missing key by petitviolet (@petitviolet) on CodePen.

先程のコードのRoutekeyを追加してみる。

-       <Route exact path={'/a'} component={MyComponent} />
-       <Route exact path={'/b'} component={MyComponent} />
+       <Route exact key={'a'} path={'/a'} component={MyComponent} />
+       <Route exact key={'b'} path={'/b'} component={MyComponent} />

動かしてみると、期待しているように動いているのがわかる。

See the Pen React Router missing key by petitviolet (@petitviolet) on CodePen.

Reactのカスタムhookで作るフォーム

何番煎じかわからないが実装してみたから残しておく。

やりたいこと

  • Reactのカスタムhookとして実装する
  • フォームのバリデーションをする
  • フォームをなるべく共通化する
    • onSubmitとかonChangeについては共通化する
    • JSXやスタイルについては共通化せず個別に実装できるようにする

流れとしてはReact hooksを自分で定義してフォームをそれっぽく扱えるようにし、そのhookを使ってログインフォームを実装する。

バージョンとか

package.jsonからTypeScript, Reactあたりを抜粋するとこんな感じ

{
  "dependencies": {
    "@types/react": "^16.9.1",
    "@types/react-dom": "^16.8.5",
    "@types/react-router-dom": "^5.1.2",
    "react": "^16.9.0",
    "react-dom": "^16.9.0",
    "react-router-dom": "^5.1.2",
    "typescript": "^3.7.2",
    ...
}

カスタムhookの実装

まずカスタムhookとしてuseFormを実装する。 フォームとしてスタイル以外のところ、Stateの管理やバリデーションなどを引き受けるコンポーネントとなる。

面倒なのでコード全体にコメントで説明。

import * as React from 'react';

// バリデーションを実行するためのI/F
export interface Validator<State extends {}> {
  runAll(state: State): Errors;
}

// バリデーションエラーをMapで保持する
export type Errors = Map<string, string>;

// カスタムhook
// - submit時に実行する関数
// - 初期state
// - バリデーション関数
export const useForm = <State extends {}>(onSubmit, initial: State, validator: Validator<State>) => {
  // 入力状態
  const [state, setState] = React.useState<State>(initial);
  // バリデーションエラー
  const [errors, setErrors] = React.useState<Errors>(new Map());
  // submitボタンが有効かどうか
  const [submitEnabled, submitEnable] = React.useState<boolean>(false);

  const isValid: () => boolean = React.useCallback(() => {
    // バリデーションを実行
    const _errors = Array.from(validator.runAll(state)).reduce<Map<string, string>>(
      (map, [key, error]) => {
        if (error) {
          return map.set(key, error);
        } else {
          return map;
        }
      },
      new Map(), // reduceの初期値
    );
    setErrors(_errors); // stateの更新
    return _errors.size == 0;
  }, [state]); 

  // submitボタンが押されたときの挙動
  const handleSubmit = React.useCallback(
    event => {
      if (event) event.preventDefault();

      if (submitEnabled && isValid()) {
        // validな入力ならsubmit可
        onSubmit(state);
      } else {
        console.log(`submit is disabled. error = ${errors}`);
      }
    },
    [state],
  );

  // 入力に変更があったときの挙動
  const handleChange = React.useCallback(
    event => {
      // 入力された値をeventから取り出す
      const name: string = event.target.name;
      const value: string = event.target.value;
      if (!submitEnabled && value.length > 0) {
        // 何かしらフォームに入力がなされているとsubmitボタンを押せるようにする
        submitEnable(true);
      }
      // stateを更新する
      setState(prevState => {
        const newState = { ...prevState, [name]: value };

        return newState;
      });
    },
    [state],
  );

  // useFormの提供するI/F
  return {
    state, // 入力された値
    errors, // バリデーションエラー
    disabled: !submitEnabled, // submit可能かどうか
    handleChange, // 入力が変更された場合のcallback
    handleSubmit, // submitされた場合のcallback
  };
};

一部のhooksでは引数として依存する値を配列で渡すことができ、大体はstateが変わらなければ結果は同じなので[state]を渡していたりする。

どうやって使うか

たとえばLoginフォーム。 こちらもコードで説明。

import * as React from 'react';
import {Redirect} from 'react-router-dom';
import {Errors, useForm, Validator} from './useForm';

// Loginフォームの状態
type LoginState = {
  readonly email: string;
  readonly password: string;
};

// useFormで要求されるフォームのバリデーション関数たち
const validator: Validator<LoginState> = new (class implements Validator<LoginState> {
  emailValidator(email: string): string | null {
    if (email.length == 0) {
      return 'Email is empty.';
    } else if (!email.match(/.+@.+\..+/)) {
      return 'Email is not valid format';
    }
  }

  passwordValidator(password: string): string | null {
    if (password.length == 0) {
      return 'Password is empty.';
    } else if (password.length < 8) {
      return 'Password min length is 8';
    } else if (password.length > 100) {
      return 'Password max length is 100';
    }
  }

  runAll(state: LoginState): Map<string, string> {
    return new Map([
      ['email', this.emailValidator(state.email)],
      ['password', this.passwordValidator(state.password)],
    ]);
  }
})();

const Login = () => {
  // ログイン状態の保持
  const [loggedIn, setLoggedIn] = React.useState(false);

  if (loggedIn) {
    // すでにloginしていたらリダイレクトする
    return <Redirect to={"/"} />;
  }

  // submit時のログイン処理
  const onSubmit = values => {
    if (values.email && values.password) {
      ApiClient // 外部APIを叩いている想定
        .login(values.email, values.password)
        .then(response => {
          setLoggedIn(true);
        })
        .catch(err => {
          console.log(`Failed to login. message = ${err}`);
        });
    } else {
      console.log('Invalid inputs');
    }
  };

  // useFormを使ったフォームの状態管理
  const { state, errors, disabled, handleChange, handleSubmit } = useForm<LoginState>(
    onSubmit, // submit時の処理
    { // 初期値
      email: '',
      password: '',
    },
    validator, // バリデーション関数たち
  );

  // フォームのJSX(スタイル)
  return (
    <form onSubmit={handleSubmit}>
      <Label>Email:
        <Input
            key={'email'}
            name={'email'}
            type={'email'}
            value={state.email}
            onChange={handleChange} />
        <div>{errors.get('email')}</div>
      </Label>
      <Label>Password:
        <Input
            key={'password'}
            name={'password'}
            type={'password'}
            value={state.password}
            onChange={handleChange} />
        <div>{errors.get('password')}</div>
      </Label>
      <button type="submit" disabled={disabled}>送信</button>
    </form>
  );
};

export default Login; 

これでログインフォームは動く、はず。 とはいえ認証した結果をLoginコンポーネント内だけで持っているのは流石に実用出来ないが。

さっくりまとめると、

  • useFormの責務
    • formの状態管理
    • submit時のバリデーションとエラーの保持
  • useFormを使う側の責務
    • JSX(スタイル含む)
    • onChange/onSubmitを適切に設定する
    • バリデーション関数の実装
    • submit時の処理(APIリクエストなど)

という感じ。 formに入力されている値の保持とかバリデーション実行とかはよくあることなので共通化してあると考える頻度が減って良い。