LoginSignup
12
0

More than 3 years have passed since last update.

JSXとvirtual-domで遊ぶ

Last updated at Posted at 2019-12-05

テックタッチアドベントカレンダー6日目を担当する@ihirokyです。
5日目は @takakobem による 妥協しないTypescript でした。
Union Types/string literal便利ですよね。@takakobem みたいにTypeScriptを語ってみたいです。が、フロント浦島な私にはまだ遠そうです。すこしでも使いこなせるよう、JSX/virtual-dom x TypeScriptでがんばります。

tl; dr

  • ReactなしでもつかえるらしいTypescriptのJSXサポート機能を使ってみた。
  • JSX単体では使いどころが難しかったので virtual-dom と組み合わせて使ってみた。
  • (果てしない・超えられない再発明)

今回作ったもの置き場:https://github.com/ihiroky/jsx-vdom

準備

$ mkdir jsx-vdom/
$ cd jsx-vdom
$ yarn init -y -p

生成された package.json に workspaces を追加

{
  "name": "jsx-vdom",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "private": true,
  "workspaces": ["packages/*"]
}
$ mkdir -p packages/string
$ cd !:1
$ yarn init -y
$ yarn add --dev browserify typescript

後にビルドで使うコマンドをscriptsに追加しておく

$ cat package.json 
{
  "name": "string",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "build": "tsc -b && browserify dist/js/test.js -o dist/bundle/bundle.js"
  },
  "devDependencies": {
    "browserify": "^16.5.0",
    "typescript": "^3.7.3"
  }
}

以下、jsx-vdom ディレクトリで作業を行うものとする。

TypeScript単体でJSXを使う

TypeScriptの型におけるJSXサポートが100%分かる記事 がとても詳しい。通常JSXは別途トランスパイルするらしいが、TypeScriptのコンパイラ設定次第では直接JavaScriptを吐くことができるのでこれを使う。初めてということで、JSXをほぼそのままHTML文字列に変換する処理を定義してみる。手順をまとめると、

  • JSX名前空間の定義
  • tsconfig.json 記述
  • JSXをそのままHTML文字列にレンダリングする jsxFactory の定義と JSX 用いた処理の記述
  • トランスパイル + browserify
  • ブラウザで動作確認

JSX名前空間の定義

JSX という名前空間に IntrinsicElements というインタフェースを定義して、そこにJSXの要素の型(JSX要素名: { 属性名: 型, ... }...)を記述する。今回は変換の様子を確かめたいので何でも入るような型を定義してお茶を濁す。また、JSX から string への変換となるので Element に string をあてる。

pakcages/string/index.d.ts
declare namespace MyJSXString.JSX {
  type Element = string
  interface IntrinsicElements {
    [name: string]: any
  }
}

MyJSXString.JSXMyJSXString 部分をのちに jsxFactory 、すなわちレンダリング用関数として扱う名称と一致させる。

tsconfig.json 記述

pakcages/string/tsconfig.json
{
  "compilerOptions": {
    "target": "es3",
    "module": "commonjs",
    "jsx": "react",
    "jsxFactory": "MyJSXString",
    "outDir": "./dist/js",
    "strict": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["*.ts", "**/*.ts", "**/*.tsx"]
}

ポイントは jsxjsxFactory。今回はTypeScript上のJSXから直接JavaScriptを吐き出すので "jsx": "react" を指定。さらに "jsxFactory": "MyJSXString" に設定する。

JSXをそのままHTML文字列に変換する jsxFactory の定義

React.createElementの定義 をみるとわかるが、jsxFactory で指定する関数は以下の3つの引数を受け取る:

  • tag名
  • props(属性)
  • 子要素

そこで、以下のように MyJSXString 関数を定義する。

pakcages/string/src/index.ts
export const MyJSXString = (tagName: string, props: {[key: string]: string}, ...children: string[]) => {
  let attrs = ''
  for (const key in props) {
    attrs += ` ${key}="${props[key]}"`
  }
  return `<${tagName}${attrs}>${children.join('')}</${tagName}>`
}

return の部分がすべてを物語ってるが、引数を並べてHTML文字列を作っている。で、これを使って処理をおこなう。JSXを使うので拡張子は .tsx

pakcages/string/src/test.tsx
import { MyJSXString } from './index'

function hello(id: string, name: string): string {
  return <div id={id}>Hello <b>{name}!</b></div>
}
function foo(hoge: string, fuga: number): string {
  return <foo hoge={hoge} fuga={fuga}></foo>
}


const e0 = hello('myid', 'JSX')
const e1 = foo('abc', 123)
document.body.insertAdjacentHTML('afterbegin', e0)
document.body.insertAdjacentHTML('afterbegin', e1)

トランスパイル + browserify

ブラウザで実行確認をするため、tscの出力をbrowserifyでバンドルする。

$ yarn workspace string build

そして bundle.js を読み込むHTMLファイル:

string/test.html
<html>
    <head>
        <script src="../dist/bundle/bundle.js"></script>
    </head>
    <body>
    </body>
</html>

ブラウザで確認

Screenshot from 2019-12-04 23-56-56.png

確かにHTML文字列が生成されていることが確認できる。tscが出力した test.js を眺めるとこんな感じ。JSXの記述が置き換わっているだけ。

dist/js/test.js
"use strict";
exports.__esModule = true;
var index_1 = require("./index");
function hello(id, name) {
    return index_1.MyJSXString("div", { id: id },
        "Hello ",
        index_1.MyJSXString("b", null,
            name,
            "!"));
}
function foo(hoge, fuga) {
    return index_1.MyJSXString("foo", { hoge: hoge, fuga: fuga });
}
var e0 = hello('myid', 'JSX');
var e1 = foo('abc', 123);
document.body.insertAdjacentHTML('afterbegin', e0);
document.body.insertAdjacentHTML('afterbegin', e1);

DOM を生成してみる

これだけではテンプレート文字列とあまりかわらないので、document.createElement() を使って JSX から DOM を生成してみる。packages/string と同様にして packages/dom を作成。

packages/dom/
├── index.d.ts
├── package.json
├── src
│   ├── index.ts
│   ├── test.html
│   └── test.tsx
└── tsconfig.json

型定義は結果の型がHTMLElementに変更。namespaceも変更。

packages/dom/index.d.ts
declare namespace MyJSXDOM.JSX {
  type Element = HTMLElement

  interface IntrinsicElements {
    [name: string]: any
  }
}

jsxFactory の実装は HTMLElementを生成するように変更。

packages/dom/src/index.ts
export const MyJSXDOM = (tagName: string, props: {[key: string]: (string | ((e: any) => any))}, ...children: (Element | string)[]) => {
  const element = document.createElement(tagName)

  for (const attr in props) {
    const p = props[attr]
    if (typeof p === 'function') {
      element.addEventListener(attr, p, false)
    } else {
      element.setAttribute(attr, p)
    }
  }

  for (const c of children) {
    const node = (typeof c === 'string') ? document.createTextNode(c) : c
    element.appendChild(node)
  }

  return element
}

tsx で document.body に appendChild してみる。

packages/dom/src/test.tsx
import { MyJSXDOM } from './index'

function onClick(e: Event) {
  if (e.target) {
    const elem = e.target as Element
    alert(elem.textContent)
  }
}

const element = <div id={'myid'} click={onClick}>Hello <b>JSX!</b></div>
document.body.appendChild(element)

結果:
Screenshot from 2019-12-05 01-51-22.png

createElement/setAttribute/addEventListner を連呼しなくて良くはなった。

virtual-dom を使う

仮想DOMの実装 https://github.com/Matt-Esch/virtual-dom を使う。Reactが流行りはじめた当時に作られた、Reactとは別の仮想DOM実装。パイオニアたちがこれで実験していた模様。READMEに使用例が記載されており、まずこれ相当の処理をTypeScriptで実装してみる。virtual-dom は4つの関数を export していて、それぞれ以下のような機能を提供する。なお、browserifyを選んだのはvirtual-domがbrowserifyっぽい感じだったから。

関数 機能
h 仮想DOMの構築
create 仮想DOMから実際のDOMを生成
diff 二つの仮想DOMから差分を計算
patch diffの結果を実際のDOMに反映する

準備

$ mkdir packages/vdom
$ cd !:1
$ yarn init -y
$ yarn add --dev browserify typescript @types/virtual-dom
$ yarn add virtual-dom
packages/vdom/src/example.ts
import { h, create, diff, patch } from 'virtual-dom'

function render(count: number)  {
    return h('div', {
        style: {
            textAlign: 'center',
            lineHeight: '100px', 
            border: '1px solid red',
            width: '100px',
            height: '100px'
        }
    }, [String(count)])
}

var count = 0
var tree = render(count)
var actualDOM = create(tree)
document.body.appendChild(actualDOM)

setInterval(() => {
      count++
      var newTree = render(count)
      var patches = diff(tree, newTree)
      console.log(patches)
      actualDOM = patch(actualDOM, patches)
      tree = newTree
}, 1000);

今までと同様にビルドコマンドを定義して

packages/vdom/package.json
{
  "name": "vdom",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "build:example": "tsc -b && browserify dist/js/example.js -o dist/bundle/bundle-example.js"
  },
  "devDependencies": {
    "@types/virtual-dom": "^2.1.0",
    "browserify": "^16.5.0",
    "typescript": "^3.7.3"
  },
  "dependencies": {
    "virtual-dom": "^2.1.1"
  }
}

ビルド

$ yarn workspace vdom build:example
yarn workspace v1.19.1
yarn run v1.19.1
$ tsc -b && browserify dist/js/example.js -o dist/bundle/bundle-example.js
Done in 3.29s.
Done in 3.80s.

ブラウザから見るとdiffの結果Text部分だけ抽出されている様子が分かる。
Screenshot from 2019-12-05 03-36-27.png

JSXの出力を仮想DOMにする

仮想DOMの生成には h 関数を使えば良いことがわかったので、これを使って jsxFactory を定義する。h の定義をみると JSX がトランスパイルされた結果並べられる引数と似ていることがわかる(型定義部分を適当に抜粋)。

packages/dom/src/index.ts
export const MyJSXDOM = (tagName: string, props: {[key: string]: (string | ((e: any) => any))}, ...children: (Element | string)[]) => {
@types/virtual-dom/index.d.ts
  type EventHandler = (...args: any[]) => void;
  interface VProperties {
    attributes?: {[index: string]: string};
    style?: any;
    [index: string]: any | string | boolean | number | VHook | EventHandler | {[index: string]: string | boolean | number};
  }

  type VTree = VText | VNode | Widget | Thunk;

  interface createProperties extends VProperties {
    key?: string;
    namespace?: string;
  }

  type VChild = VTree[] | VTree | string[] | string;
  function h(tagName: string, properties: createProperties, children: string | VChild[]): VNode;

つまりJSX名前空間でJSXの変換結果が VNode になるように設定すれば、h関数をそのままjsxFactoryに指定できる。が、後で処理をはさむのでhをラップするような変換関数を定義する。

packages/vdom/index.d.ts
declare namespace MyJSXVDOM.JSX {
  type Element = VirtualDOM.VNode

  interface IntrinsicElements {
    [name: string]: any
  }
}
packages/vdom/src/index.ts
import { h } from 'virtual-dom'

export const MyJSXVDOM = (tag: string, props: VirtualDOM.createProperties, children: string | VirtualDOM.VChild[]): VirtualDOM.VNode => {
  return h(tag, props, children)
}

packages/vdom/tsconfig.json (抜粋)
  "jsxFactory": "MyJSXVDOM",

あとはJSXを使う処理を記述して仮想DOMによる描画処理を記述できる。

packages/vdom/src/test.tsx
import { create, diff, patch } from 'virtual-dom'
import { MyJSXVDOM } from './index'

namespace MyVDOM {
  let virtualDOM: VirtualDOM.VNode
  let actualDOM: Element
  let renderVDOM: () => VirtualDOM.VNode

  export function render(render: () => VirtualDOM.VNode, container: HTMLElement) {
    renderVDOM =  render
    virtualDOM = render()
    console.log(virtualDOM)
    actualDOM = create(virtualDOM)
    container.appendChild(actualDOM)
  }

  export function update() {
    var newVirtualDOM = renderVDOM()
    var patches = diff(virtualDOM, newVirtualDOM)
    actualDOM = patch(actualDOM, patches)
    virtualDOM = newVirtualDOM
  }
}

var count = 0

function onClick(e: Event) {
  count++
  MyVDOM.update()
}

function Hello() {
  return <div id={'myid'} onclick={onClick}>Hello <b>JSX!</b><b>VDOM!</b>{count}</div>
}

MyVDOM.render(Hello, document.body)

文字列をクリックするとカウントアップしていく。
Screenshot from 2019-12-05 05-04-04.png

<関数/>

プロダクションレベルには程遠いが、とりあえず晴れて仮想DOMによるレンダリングができるようなった。ところで、JSXを含んだコードを見ていると見たことないタグをよく見る。

const c = <App/> // こんなの

JSXは関数やクラスもタグ風にかけるらしく、まず関数を用いる場合を試す。
まず、以下のコードが含まれるtsxをトランスパイルすると

import { MyJSXVDOM } from './index'

function Hello() {
  return <div id={'myid'} onclick={onClick}>Hello <b>JSX!</b> <b>VDOM!</b> {count}</div>
}

const a = <Hello/>

以下のコードが生成される。HelloのHが大文字なのが大事。1

function Hello() {
    return index_1.MyJSXVDOM("div", { id: 'myid', onclick: onClick },
        "Hello ",
        index_1.MyJSXVDOM("b", null, "JSX!"),
        " ",
        index_1.MyJSXVDOM("b", null, "VDOM!"),
        " ",
        count);
}
var a = index_1.MyJSXVDOM(Hello, null);

JSXの変換関数に関数が渡されるように解釈されている。そこで、MyJSXVDOMの第1引数で関数も受け取れるように変更し、関数が渡されたときはこれを呼び出すように変更する:

packages/vdom/src/index.ts
import { h } from 'virtual-dom'

export const MyJSXVDOM = (
    tagOrFunc: string | ((props: VirtualDOM.createProperties, children: string | VirtualDOM.VChild[]) => VirtualDOM.VNode),
    props: VirtualDOM.createProperties,
    ...children: (string | VirtualDOM.VChild)[]): VirtualDOM.VNode => {
  if (typeof tagOrFunc === 'string') {
    return h(tagOrFunc, props, children)
  }

  return tagOrFunc(props, children)  
}

これで <関数/> を解釈できるようになる。そこで、test.tsの最終行を以下のように変更してみても全く同じ動きになる。

packages/vdom/src/test.tsx (最終行)
MyVDOM.render(() => <Hello/>, document.body)

<クラス/>

こちらも同様に以下のコードをトランスパイルすると

class Hi {
  render() {
    return <div></div>
  }
}
const c = <Hi/>

こうなる

var Hi = /** @class */ (function () {
    function Hi() {
    }
    Hi.prototype.render = function () {
        return index_1.MyJSXVDOM("div", null);
    };
    return Hi;
}());
var c = index_1.MyJSXVDOM(Hi, null);

コンストラクタ相当の関数が渡る。これで値は返せないので、render側から値が変えるようにする。コンストラクタが変換関数に渡ってきたときに処理を分岐させる必要があるため、型定義も追加する。

packages/vdom/index.d.ts
declare namespace MyJSXVDOM.JSX {
  type Element = VirtualDOM.VNode

  interface IntrinsicElements {
    [name: string]: any
  }
}

interface ComponentConstructor {
  new(props: VirtualDOM.createProperties, children: string | VirtualDOM.VChild[]): Component
}

interface Component {
  render(): VirtualDOM.VNode
}

変換関数に必要なタイプガードを追加し、引数による分岐に利用する。

packages/vdom/src/index.ts
import { h } from 'virtual-dom'

function isComponentConstructor(c: any): c is ComponentConstructor {
  return c !== null
    && typeof c === 'function'
    && typeof c.prototype.render === 'function'
}

export const MyJSXVDOM = (
    tagOrCompOrFunc: string | ((props: VirtualDOM.createProperties, children: string | VirtualDOM.VChild[]) => VirtualDOM.VNode) | ComponentConstructor,
    props: VirtualDOM.createProperties,
    ...children: (string | VirtualDOM.VChild)[]): VirtualDOM.VNode => {

  if (typeof tagOrCompOrFunc === 'string') {
    return h(tagOrCompOrFunc, props, children)
  } else if (isComponentConstructor(tagOrCompOrFunc)) {
    const c = new tagOrCompOrFunc(props, children)
    return c.render()
  } else {
    return tagOrCompOrFunc(props, children)
  }
}

最後に render(): VNode を持つクラスを定義してタグっぽく書く。ついでに色々まとめる。

packages/vdom/src/test.tsx
var countGlobal = 0

function onClickGlobal(e: Event) {
  countGlobal++
  MyVDOM.update()
}

function HelloGlobal() {
  return <div id={'myidglobal'} onclick={onClickGlobal}>Hello <b>JSX!</b> <b>VDOM!</b> {countGlobal}</div>
}

class HelloLocal {
  private count: number

  constructor() {
    this.count = 0
    this.getCount = this.getCount.bind(this)
    this.onClick = this.onClick.bind(this)
  }

  getCount() {
    return this.count
  }

  onClick(e: Event) {
    this.count++
    MyVDOM.update()
  }

  render() {
    return <div id={'myidlocal'} onclick={this.onClick}>Hello <b>JSX!</b> <b>VDOM!</b> {this.getCount()}</div>
  }
}

// MyVDOM.render(HelloGlobal, document.body)
// MyVDOM.render(() => <HelloGlobal/>, document.body)
MyVDOM.render(() => <HelloLocal/>, document.body)

これで <クラス/> もつかえ・・・ない。JSXの変換時に都度 new がはしり新しいオブジェクトが生成されるため、 count プロパティの値が保持されない。なんとか状態を維持するために・・・。

おわりに

もう1回担当が回ってくるのでそこで続きをやるかもしれません。かもしれません。

7日目は @analogrecord による「goのhot-reload」です。お楽しみに〜。

元々

もともとは @mxxxxkxxxx さんの社内つぶやきでJSXを触ってみたところから発展した記事です。感謝してます、というかつかっちゃってすみません。

12
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
0