BrowserifyのリアルタイムReactコンポーネントの更新





すべての人に良い一日を!

DX(開発者エクスペリエンス)または「開発エクスペリエンス」について、具体的には、システムの状態を維持しながらリアルタイムでコードを更新することについて少し話しましょう。 トピックが初めての場合は、読む前に次のビデオを読むことをお勧めします。



ページをリロードせずにリアルタイムでコードを更新する一連のビデオ








はじめに:どのように機能しますか?



まず、このような機能の実装は多くのタスクの解決を意味することを理解する必要があります。

-ファイルの変更を追跡する

-ファイルの変更に基づいたパッチ計算

-クライアントへのパッチの転送(ブラウザなど)

-パッチを処理して既存のコードに適用する

しかし、まず最初に。



ファイルの変更を追跡する



私の経験では、4つの異なる実装を試しました。

-githubからのソリューション

-ネイティブfs.watch

- チョキダー

- 視線

あるアプリケーションの別のアプリケーションに対する利点については長い間議論することができますが、私自身はchokidarを選択しました。これは高速で便利で、OS Xでうまく機能します(おかげで、 paulmillr )。



このステップでのタスクは、バンドルファイルへの変更を追跡し、それらの変更に対応することです。 ただし、1つの問題があります。browserifyはバンドル記録をストリーミング記録モードで開きます。つまり、記録が終了する前に"change"



イベントが数回発生する可能性があります(残念ながら、そのようなイベントはありません)。 したがって、無効なパッチで問題が発生する可能性がある状況を回避するために、追加のコード検証チェックを含める必要があります(ファイル内のデータの存在と構文エラーを簡単にチェックします)。 この部分では、明らかなようです。 さて、先に進みますか?



ファイルの変更に基づいたパッチ計算



バンドルファイルのみの変更を追跡します。 これらのファイルのいずれかが変更されるとすぐに、古いバージョンのファイルのパッチを計算し、クライアントに転送する必要があります。 現時点では、 browserifyのリアルタイムのリアクションコードを操作するときにlivereactloadが積極的に使用されています。これにより、この問題は大きなオーバーヘッドで解決されます。バンドル全体が毎回提供されます。 私にとっては、やりすぎです。 ソースマップを含むバンドルの重量が10 MBの場合はどうなりますか? そのようなトラフィックを促進するためにコンマを追加してください? まあ、いや...



browserifyはwebpackのように「ホットスワップモジュール」のオプションを提供しないため、実行時にコードを「置換」することはできません。 しかし、おそらくこれはさらに優れており、さらにトリッキーになる可能性があります!



ビバjsdiff ! ファイルのコンテンツの初期バージョンと修正バージョンを彼に提供し、出力を取得します-アトミックな変更(個人的にはくしゃみごとにcmd + sを押します)で実際の差分は約1Kbです。 そして何がさらに楽しい-それは読みやすいです! しかし、すべてには時間があります。 次に、この差分をクライアントに渡す必要があります。



クライアントへのパッチの移送



この部分では魔法は予見されていません。次のメッセージを送信できる通常のWebSocket接続です。



-すべてがうまくいき、diffが正常に計算され、エラーが発生しなかった場合、フォーマットのメッセージをクライアントに送信します

 { "bundle": BundleName <String>, //     bundle- "patch": Patch <String> //     }
      
      





-すべてが順調に進まなかった場合、差分の計算時に構文エラーが見つかりました:

 { "bundle": BundleName <String>, //    bundle-,    "error": Error <String> //    }
      
      





-新しいクライアントがセッションに参加すると、すべての「ソース」が彼に送信されます。

 { "message": "Connected to browserify-patch-server", "sources": sources <Array>, //     bundle- }
      
      





ここでソースを見ることができます



パッチを処理して既存のコードに適用する



主な魔法はこのステップで発生します。 パッチを入手したと仮定すると、それは正しく、現在のコードに適用できます。 次は?

そして、少し余談をして、browserifyがファイルをラップする方法を確認する必要があります。 正直なところ、これをシンプルで理解しやすい言語で説明するためには、Ben Klinkenbirdの優れた記事を翻訳するのが最善ですが、代わりに私はおそらく資料の研究を読み手に任せるでしょう。 最も重要なことは、各モジュールスコープのDIです。



記事の例
 { 1: [function (require, module, exports) { module.exports = 'DEP'; }, {}], 2: [function (require, module, exports) { require('./dep'); module.exports = 'ENTRY'; }, {"./dep": 1}] }
      
      







これがrequire



関数とmodule



アクセスし、オブジェクトをexports



する方法です。 私たちの場合、通常のrequire



は十分ではありません:パッチを操作するロジックをカプセル化する必要があります(各モジュールの手でこれを書くつもりはありません)! これを行う最も簡単な方法は、唯一ではないにしても、 require



をオーバーロードrequire



。 これはまさにこのファイルで私がすることです



overrideRequire.js
 function isReloadable(name) { // @todo Replace this sketch by normal one return name.indexOf('react') === -1; } module.exports = function makeOverrideRequire(scope, req) { return function overrideRequire(name) { if (!isReloadable(name)) { if (name === 'react') { return scope.React; } else if (name === 'react-dom') { return scope.ReactDOM; } } else { scope.modules = scope.modules || {}; scope.modules[name] = req(name); return scope.modules[name]; } }; };
      
      







お気づきかもしれませんが、コードではscope



を使用していwindow



。これは、 window



参照していwindow



makeOverrideRequire



関数makeOverrideRequire



req



使用しますが、これは元のrequire



関数にすぎません。 ご覧のとおり、すべてのモジュールはscope.modulesにプロキシされており、いつでもアクセスできるようになっています(これは今後見つかる可能性があります。そうでない場合はキャンセルします)。 また、上記のコードからわかるように、 react



モジュールがohmかreact-dom



かどうかを確認します。 この場合、単純にスコープからオブジェクトへのリンクを返します(Reactの異なるバージョンを使用している場合、サービスgetRootInstances



が別のオブジェクトを指すため、hot-loader-apiを使用するとエラーが発生します)。



それでは、さらに先に進みましょう- ソケットを操作します



injectWebSocket.js
 var moment = require('moment'); var Logdown = require('logdown'); var diff = require('diff'); var system = new Logdown({ prefix: '[BDS:SYSTEM]', }); var error = new Logdown({ prefix: '[BDS:ERROR]', }); var message = new Logdown({ prefix: '[BDS:MSG]', }); var size = 0; var port = 8081; var patched; var timestamp; var data; /** * Convert bytes to kb + round it to xx.xx mask * @param {Number} bytes * @return {Number} */ function bytesToKb(bytes) { return Math.round((bytes / 1024) * 100) / 100; } module.exports = function injectWebSocket(scope, options) { if (scope.ws) return; if (options.port) port = options.port; scope.ws = new WebSocket('ws://localhost:' + port); scope.ws.onmessage = function onMessage(res) { timestamp = '['+ moment().format('HH:mm:ss') + ']'; data = JSON.parse(res.data); /** * Check for errors * @param {String} data.error */ if (data.error) { var errObj = data.error.match(/console.error\("(.+)"\)/)[1].split(': '); var errType = errObj[0]; var errFile = errObj[1]; var errMsg = errObj[2].match(/(.+) while parsing file/)[1]; error.error(timestamp + ' Bundle *' + data.bundle + '* is corrupted:' + '\n\n ' + errFile + '\n\t ' + errMsg + '\n'); } /** * Setup initial bundles * @param {String} data.sources */ if (data.sources) { scope.bundles = data.sources; scope.bundles.forEach(function iterateBundles(bundle) { system.log(timestamp + ' Initial bundle size: *' + bytesToKb(bundle.content.length) + 'kb*'); }); } /** * Apply patch to initial bundle * @param {Diff} data.patch */ if (data.patch) { console.groupCollapsed(timestamp, 'Patch for', data.bundle); system.log('Received patch for *' + data.bundle + '* (' + bytesToKb(data.patch.length) + 'kb)'); var source = scope.bundles.filter(function filterBundle(bundle) { return bundle.file === data.bundle; })[0].content; system.log('Patch content:\n\n', data.patch, '\n\n'); try { patched = diff.applyPatch(source, data.patch); } catch (e) { return error.error('Patch failed. Can\'t apply last patch to source: ' + e); } Function('return ' + patched)(); scope.bundles.forEach(function iterateBundles(bundle) { if (bundle.file === data.bundle) { bundle.content = patched; } }); system.log('Applied patch to *' + data.bundle + '*'); console.groupEnd(); } /** * Some other info messages * @param {String} data.message */ if (data.message) { message.log(timestamp + ' ' + data.message); } }; };
      
      







diff.applyPatch(source, data.patch)



を使用しない限り、特別なことではないようです。 この関数を呼び出した結果、パッチされたソースを取得します。これは、 Function



介してコード内でさらに美しく呼び出されます。



最後ですが、非常に重要なのは、 injectReactDeps.jsです。



injectReactDeps.js
 module.exports = function injectReactDeps(scope) { scope.React = require('react'); scope.ReactMount = require('react/lib/ReactMount'); scope.makeHot = require('react-hot-api')( function getRootInstances() { return scope.ReactMount._instancesByReactRootID; } ); };
      
      







プログラム全体のフードの下で、 ダニエル・アブラモフ(別名ガエアロン)の react-hot-api



心臓が鼓動します。 このライブラリは、モジュールのエクスポートを置き換え(コンポーネントを読み取り)、変更されるとプロトタイプを「パッチ」します。 これは時計のように機能しますが、いくつかの制限があります。「パッチ」の間に、反応コンポーネントから引き裂かれたすべてのスコープ変数が失われます。 また、コンポーネントの状態を操作する上で多くの制限があります。要素の初期状態を変更することはできません-これには再起動が必要です。



browserify変換を実装するtransform.jsファイルをまとめる代わりに、これらすべてのファイル間のリンクとして機能することで、アイデア全体を実現できることは言うまでもありません。



transform.js
 const through = require('through2'); const pjson = require('../package.json'); /** * Resolve path to library file * @param {String} file * @return {String} */ function pathTo(file) { return pjson.name + '/src/' + file; } /** * Initialize react live patch * @description Inject React & WS, create namespace * @param {Object} options * @return {String} */ function initialize(options) { return '\n' + 'const options = JSON.parse(\'' + JSON.stringify(options) + '\');\n' + 'const scope = window.__hmr = (window.__hmr || {});\n' + '(function() {\n' + 'if (typeof window === \'undefined\') return;\n' + 'if (!scope.initialized) {\n' + 'require("' + pathTo('injectReactDeps') + '")(scope, options);\n' + 'require("' + pathTo('injectWebSocket') + '")(scope, options);' + 'scope.initialized = true;\n' + '}\n' + '})();\n'; } /** * Override require to proxy react/component require * @return {String} */ function overrideRequire() { return '\n' + 'require = require("' + pathTo('overrideRequire') + '")' + '(scope, require);'; } /** * Decorate every component module by `react-hot-api` makeHot method * @return {String} */ function overrideExports() { return '\n' + ';(function() {\n' + 'if (module.exports.name || module.exports.displayName) {\n' + 'module.exports = scope.makeHot(module.exports);\n' + '}\n' + '})();\n'; } module.exports = function applyReactHotAPI(file, options) { var content = []; return through( function transform(part, enc, next) { content.push(part); next(); }, function finish(done) { content = content.join(''); const bundle = initialize(options) + overrideRequire() + content + overrideExports(); this.push(bundle); done(); } ); };
      
      









アプリケーションアーキテクチャ



アプリケーションは、 サーバークライアントの 2つの部分で構成されています



-サーバーはバンドルファイルのオブザーバーとして機能し、変更されたバージョン間の差分を計算し、接続されているすべてのクライアントに即座に通知します。 サーバーメッセージとそのソースコードの説明はこちらにあります

もちろん、このサーバーをベースにしたライブラリ/フレームワーク用のライブパッチプログラムを作成できます。



-この場合のクライアントは、WebSocketを使用してサーバーに接続し、そのメッセージを処理する(パッチを適用してバンドルをリロードする)トランスフォームを介して組み込まれたプログラムです。 ソースコードとお客様のドキュメントはこちらにあります



触ってみましょう



Unix / OS Xでは、次のコマンドを使用して例を足場にすることができます。



 git clone https://github.com/Kureev/browserify-react-live.git cd browserify-react-live/examples/01\ -\ Basic npm i && npm start
      
      





Windowsでは、2行目(スラッシュの問題)を変更する必要があると思います。誰かが正しいオプションをテストして書いてくれたら嬉しいです。



これら3つのコマンドを実行すると、コンソールに次のようなものが表示されるはずです。







コンソールがすべての準備ができたことを喜んで伝えたら、 http:// localhost:8080に移動します







これはあなた次第です:browserify-react-live / examples / 01-Basic / components / MyComponent.jsに行き、コードを変更します。



たとえば、「増加」ボタンを数回クリックすることで、+ 1が弱虫用であると判断し、コード内で変更しました



 this.setState({ counter: this.state.counter + 1 });
      
      









 this.setState({ counter: this.state.counter + 2 });
      
      





保存した後、ブラウザにパッチを適用した結果が表示されます。







できた! もう一度「増加」をクリックしてみましょう-カウンターが2つ増えました! 利益!



結論の代わりに



-正直なところ、最近までlivereactloadが機能することを望み、実装を作成する必要はありませんでしたが、数か月の差のある2回の試行の後、まだ良い結果を達成できませんでした(状態システムは常に飛行していました)。

-おそらく私は何かを見逃したか、改善のための提案があります-それについて私に書くことをheしないでください、一緒に私たちは世界を少し良くすることができます:)

-フィールドテストを手伝ってくれたすべての人に感謝します。



All Articles