在前端開發中,生態系最豐富且最有宰制力的程式語言是 JavaScript。Flutter 作為後進者,很多方便的套件都沒有,要是能直接在 Flutter 上使用 JavaScript 存在的資源,是不是太棒了? 本篇告訴你怎麼在 Flutter 上偷吃步,不用每一個需求都自己刻 。

要在 Flutter 中使用 JavaScript 的 NPM 模組,我們可以透過一種巧妙的方法來實現語言互操作性(Interoperability)。簡單來說,就是使用 Webpack 將 JavaScript 模組轉譯並打包成一個單一的 bundle.js 文件,然後將其導出到物件物件(global)。這樣我們,便可以在 Flutter 應用中利用 flutter-js 這個套件來執行這些 JavaScript 源碼。

首先,我們需要明白 Flutter 本身是不直接支持執行 JavaScript 源碼的。因此,我們需要借助一些工具和技術來搭建一個橋樑,使得 Flutter 能夠理解和執行 JavaScript 源碼。Webpack 在這裡扮演了關鍵的角色。它不僅能夠處理 JavaScript 模組之間的依賴關係,還能將這些模組打包成一個單一的文件,這對於後續的整合和執行非常有幫助。

接下來,當我們有了打包好的 bundle.js 文件後,就可以在 Flutter 應用中透過 flutter-js 包來讀取和執行這個文件了。flutter-js 提供了 loadString 方法,允許我們將 JavaScript 源碼作為字串讀取進來。這個過程類似於在瀏覽器中載入和執行 JavaScript,但這裡是在 Flutter 的環境中進行。

當 JavaScript 源碼被成功執行後,我們可以通過 jsResult.stringResult 來獲取執行結果。這個結果通常是一個字符串,我們可以將其轉換成 Dart 能夠理解的數據結構。例如,如果 JavaScript 源碼返回的是 JSON 字符串,我們可以使用 Dart 的 json.decode 方法來解析這個字符串,並將其轉換成 Dart 對象。

了解這個基本概念後,我們便可以開始學習,怎麼在自己的 Flutter 應用上使用 NPM 套件。

Dart 跟 Javascript 互操作

在不同的編程語言之間進行變數存取和函數執行的過程,通常被稱為「語言互操作性」(Interoperability)。這種互操作性指的是不同編程語言和環境之間能夠相互交互和協作的能力,包括共享數據、呼叫方法或函數等。

實現語言互操作性時,通常涉及以下幾個方面:

  1. 數據類型轉換:由於不同語言之間的數據類型可能有所不同,因此需要進行適當的轉換,以確保數據在語言之間能夠正確傳遞。
  2. 函數呼叫約定:不同的編程語言可能有不同的函數呼叫方式,實現互操作性時需要處理這些差異。
  3. 運行時環境:不同的語言可能運行在不同的環境中,例如,一種語言可能運行在虛擬機上,而另一種則直接編譯為機器碼。
  4. 介面和橋接技術:通常需要使用特定的介面或橋接技術來實現不同語言之間的互操作性,例如使用 API、RPC(遠程過程呼叫)、FFI(外部函數介面)等。

根據上述,我們要完成的任務剩下 1 跟2。3 跟 4 的部分 flutter-js 已經處理的非常完美,且支援多種 JavaScript 引擎,包括但不限於 QuickJSJavaScriptCore,這使得它能夠在不同的平台上提供穩定且高效的性能。像是 Android、Windows、MacOS、iOS 等都可以使用。

了解理論面後,接來講操作面。首先,我們要安裝 flutter_js 套件,

flutter pub add flutter_js

第二,建立要儲存 NPM 模組的地方。

mkdir -p assets/js/

第三,修改 pubspec.yaml,讓 Flutter 可以讀取到 assests 下的資源,否則會出現錯誤。

  #   - images/a_dot_ham.jpeg
  assets:
    - assets/
    - assets/js/
    - assets/js/dist/

第四,初始化 asssts 成為一個 npm 套件,這個套件,就是我們用來管理所有 Javascript 套件相依性,跟打包成一個巨大的 build.js ,讓 Flutter 載入的地方。

cd assets/js & npm init
npm install --save-dev webpack

第五,安裝我們要的模組,這便就看你的需求了。這裏以 uniorg-parseunified 為例。uniorg-parseunified 的 plugin,可以用來 parse orgmode 的語法,轉為 JSON。

npm install uniorg-parse unified

然後建立 assets/js/src/index.js ,並輸入如下內容:

var parse = require('uniorg-parse');
var unified = require('unified');

module.exports.unified = unified;
module.exports.ds = datascript;
module.exports.parse = parse;

第六,建立 assets/js/webpack.config.js, 並輸入如下內容:

var path = require('path')

module.exports = [{
  entry: ['./src/index'], // 在 index 檔案後的 .js 副檔名是可選的
  output: {
    libraryTarget: "var",
    library: 'vendor',
    path: path.join(__dirname, 'dist'),
    filename: 'bundle.js'
  },
}]

由於 Webpack 需要將 libraryTarget 設定成 var, 才能把 module expose 到 global , 為了更好的封裝性,我們另外設定 library 將 variable 名稱設定為 vendor 。好了,現在 vendor.ds 可以存取 unified 模組, 而 vendor.unified.parse 可以直接對應到 uniorg.parse

import 'package:flutter_js/flutter_js.dart';
import 'package:flutter_js_context/flutter_js_context.dart';

var context= JSContext();
var result = await context.evaluateAsync("""
var processor = vendor.unified().use(vendor.parse);
JSON.stringify(processor.parse('$strippedText'));
""");

在這段源碼中,context.evaluateAsync 是一個異步函數,用於在 Dart 的環境中執行 JavaScript 源碼。源碼中首先創建了一個 processor 變量,這是通過 vendor.unified() 並使用 vendor.parse 方法得到的。接著,源碼使用 processor.parse 方法處理 $strippedText 字符串,並將結果轉換為 JSON 字符串。整個 JavaScript 源碼塊的執行結果被賦值給 result 變量,這樣就能在 Flutter 應用中異步地獲取並使用 JavaScript 處理的數據。

各位可以發現到上面的源碼用了flutter_js_context 的 Flutter 套件,專門用於實現 Flutter 應用與 JavaScript 源碼之間的互操作性。這個套件旨在簡化在 Flutter 中集成和運行 JavaScript 源碼的過程

功能有

  1. 使用這個套件可以追蹤在 JavaScript 執行環境中哪些變數已經被定義,從而更好地管理和理解源碼的執行狀態。
  2. 該套件假設所有評估結果的類型為 JSON,並提供相應的解碼功能。這使得從 JavaScript 返回的資料結構可以方便地轉換為 Flutter 對應的型別。
  3. 正確載入和執行 JavaScript 文件,確保源碼可以在 Flutter 的 JavaScript Runtime 執行。

用法如下

import 'package:flutter_js_context/flutter_js_context.dart';

void main() {
  final context = JsContext();

  JsRef obj = JsRef.define(context, 'myvar', '1');

  // equals 'var myvar[ref.key] = 4;' in JavaScript.
  obj.update("4");

  // plus the object's value which is 4 and 4 in javascript runtime.
  context.evaluate("${obj.toJsCode()} + 4"); // 8

  // plus the object's value which is 8 and 4 in dart.
  print(obj.value + 4); // 12
}

到此為止,我們已經完成 Dart 跟 JavaScript 的互操作性,但用起來還沒很方便。我們還需要製作 Dart Wrapper ,將 JavaScript 的低階操作進行封裝。

Dart Wrapper

Dart Wrapper 將對 JSContext 封裝起來,並提供額外的行為和操作。把所有透過跟 JSContext 跟 JavaScript 的打交道的繁瑣隱藏起來,讓 Flutter 開發者能夠以他們熟悉的 Dart 語言進行操作。

做法是把拿到的 Javasciprt 回傳的值,轉為 JSON,Dart 要傳進去的值轉為 JSON。在這種模式中,Dart 扮演著客戶端的角色,而 JavaScript 則相當於後端服務器。這種類比有助於理解 Flutter 應用中的數據處理流程。

在這個模式下,dart:convert 中的 json.decodejson.encode 函數用於在 Dart(客戶端)與 JSON(來自後端的數據格式)之間進行數據轉換。這類似於在 Web 開發中,瀏覽器(客戶端)與服務器之間的數據交換。儘管這種方法直接且簡單,但它在一定程度上失去了靜態類型語言的優勢,並且類型只能在運行時確定。

為了在 Flutter 開發中利用 Dart 的靜態類型系統,我們可以採用 JSON 模型化的方法。這涉及到在編譯階段生成專門的 Dart 類別來處理 JSON 序列化和反序列化,類似於在 Web 開發中客戶端與服務器端代碼的分離。

這可以通過使用 json_serializable 官方套件或手動撰寫 Dart 類別和轉換器來實現。此外,Flutter China 開發的 json_model 套件提供了一種自動從 JSON 文件生成 Dart 類別的方法,進一步簡化了這一過程。這種方法使得在 Flutter 中處理 JSON 數據時,開發者可以像處理客戶端和服務器端代碼一樣,保持代碼的類型安全和結構清晰。

下面用我所撰寫的 flutter-js-orgmode舉例。這個套件支援大部分 org-mode 語法,通過 JavaScript 生態系統中的 uniorg 函式庫實現最精確的解析。簡來說就是有一個 OrgParser Dart class,提供一個 parse 方法,可以直接呼叫運行在 JavaScript 環境的 uniorg.parse,然後回傳解析完後的 Dart 型別。

首先我們先定義一個 OrdData ,負責 JSON 的序列化跟反序列化。實作方式請參考其 json_serializable 文檔,這裡不贅述。

import 'package:json_annotation/json_annotation.dart';

@JsonSerializable()
class OrgData extends GreaterElementMeta implements GreaterElement {
  @override
  final String type = 'org-data';

  @override
  @NodeListConverter()
  final List children;

  /// Org displays this title. For long titles, use multiple ‘#+TITLE’ lines.
  String? title;

  String? author;

  String? date;

  ...
}

json_serialization 會幫你生成 _$OrgDataToJson 的源碼。 這個函式會幫你把傳進來的 JSON 解析成 Dart 型別,因此你只要再加一個 fromJson 方法,接到,_$OrgDataToJson

  factory OrgData.fromJson(Map<String, dynamic> json) =>
      _$OrgDataFromJson(json);
  Map<String, dynamic> toJson() => _$OrgDataToJson(this);

這樣,我們就能把 JavaScript 源碼傳回來的結果,透過 fromJSON 方法,解析成 Dart 型別。了解這些概念後,我們終於可以開始撰寫真正的 Wrapper

然後就是 OrgParser ,他的任務是解析存在 jsBundle 變數的 uniorg JavaScript 源碼,然後實作前面提到的 parse

class OrgParser {

  JsContext context;

  OrgParser() : context = JsContext() {
 .  // 這段不能省略,否則你在 JavaScript 環境,便無法呼叫 `uniorg`
    // 的任何函式。可以把它想成 Node.js 的 `import`。
    context.evaluate(jsBundle);
  }

  /// Parses [text] and returns [OrgData].
  ///
  /// Returns null if the processing is failed.
  Future<OrgData?> parse(String text) async {
    final encodedText = json.encode(text);
    // remove " append in the start and end position which make parsing incorrect.
    final strippedText = encodedText.substring(1, encodedText.length - 1);
    try {
      var result = await context.evaluateAsync("""
      var processor = vendor.unified().use(vendor.parse);
      JSON.stringify(processor.parse('$strippedText'));
     """);
      return OrgData.fromJson(jsonDecode(result));
    } catch (e) {
      debugPrint(
          "The following text can not be parsed:\n $text \n failure:\n $e");
      return null;
    }
  }
}

然後把這些 Wrapper 打包成 Dart 套件,就可以直接在使用 Flutter 中使用了。你看下面這源碼,用起來是不是跟用原生的 Dart 套件一模一樣。

import 'package:flutter_js_orgmode/flutter_js_orgmode.dart';

final orgdata = OrgParser().parse(
"""
#+title: Dart
* TODO [#A] Headline mei
- 測試 1234
- 2
* Just
""").then((orgdata) {
  print(orgdata.children[1].children[0].todoKeyword); // TODO.
});

結論

透過這種手法,你可以將任意 NPM 套件拿來使用在你的 Flutter 應用上。你可以直接閱讀我做的 flutter-js-orgmodeflutter-js-datascript 源碼,了解更多細節。