Saltar al contenido principal

Uso de JS interop

Cómo declarar y utilizar miembros de interoperabilidad con JS.

JS interop proporciona los mecanismos para interactuar con las APIs de JavaScript desde Dart. Te permite invocar estas APIs e interactuar con los valores que obtienes de ellas usando una sintaxis explícita e idiomática.

Normalmente, accedes a una API de JavaScript haciéndola disponible en algún lugar del alcance global de JS (global JS scope). Para llamar y recibir valores de JS desde esta API, utilizas miembros de interoperabilidad externos (external interop members). Para construir y proporcionar tipos para los valores de JS, utilizas y declaras tipos de interoperabilidad, que también contienen miembros de interoperabilidad. Para pasar valores de Dart como una List o Function a los miembros de interoperabilidad o convertir valores de JS a valores de Dart, utilizas funciones de conversión, a menos que el miembro de interoperabilidad contenga un tipo primitivo.

Tipos de interoperabilidad

#

Al interactuar con un valor de JS, debes proporcionarle un tipo de Dart. Puedes hacer esto utilizando o declarando un tipo de interoperabilidad. Los tipos de interoperabilidad son un "tipo de JS" proporcionado por Dart o un extension type que envuelve un tipo de interoperabilidad.

Los tipos de interoperabilidad te permiten proporcionar una interfaz para un valor de JS y te permiten declarar APIs de interoperabilidad para sus miembros. También se utilizan en la firma de otras APIs de interoperabilidad.

dart
extension type Window(JSObject _) implements JSObject {}

Window es un tipo de interoperabilidad para un JSObject arbitrario. No hay una garantía en tiempo de ejecución de que Window sea realmente un Window de JS. Tampoco hay conflicto con ninguna otra interfaz de interoperabilidad que esté definida para el mismo valor. Si deseas verificar que Window es realmente un Window de JS, puedes verificar el tipo del valor de JS a través de la interoperabilidad.

También puedes declarar tu propio tipo de interoperabilidad para los tipos de JS que Dart proporciona envolviéndolos:

dart
extension type Array._(JSArray<JSAny?> _) implements JSArray<JSAny?> {
  external Array();
}

En la mayoría de los casos, probablemente declararás un tipo de interoperabilidad utilizando JSObject como el tipo de representación (representation type), porque probablemente estarás interactuando con objetos de JS que no tienen un tipo de interoperabilidad proporcionado por Dart.

Los tipos de interoperabilidad también deberían, por lo general, implementar su tipo de representación para que puedan ser usados donde se espera el tipo de representación, como en muchas APIs proporcionadas por package:web.

Miembros de interoperabilidad

#

Los miembros de interoperabilidad external proporcionan una sintaxis idiomática para los miembros de JS. Te permiten escribir una firma de tipo de Dart para sus argumentos y valor de retorno. Los tipos que se pueden escribir en la firma de estos miembros tienen restricciones. La API de JS a la que corresponde el miembro de interoperabilidad se determina por una combinación de dónde está declarado, su nombre, qué tipo de miembro de Dart es y cualquier renombramiento (rename).

Miembros de interoperabilidad de nivel superior

#

Dados los siguientes miembros de JS:

js
globalThis.name = 'global';
globalThis.isNameEmpty = function() {
  return globalThis.name.length == 0;
}

Puedes escribir miembros de interoperabilidad para ellos de esta manera:

dart
@JS()
external String get name;

@JS()
external set name(String value);

@JS()
external bool isNameEmpty();

Aquí, existe una propiedad name y una función isNameEmpty que están expuestas en el alcance global. Para acceder a ellas, utilizas miembros de interoperabilidad de nivel superior. Para obtener y establecer name, declara y usa un getter y setter de interoperabilidad con el mismo nombre. Para usar isNameEmpty, declara y llama a una función de interoperabilidad con el mismo nombre. Puedes declarar getters, setters, métodos y campos de interoperabilidad de nivel superior. Los campos de interoperabilidad son equivalentes a pares de getter y setter.

Los miembros de interoperabilidad de nivel superior deben declararse con una anotación @JS() para distinguirlos de otros miembros de nivel superior external, como aquellos que se pueden escribir usando dart:ffi.

Miembros de tipos de interoperabilidad

#

Dada una interfaz de JS como la siguiente:

js
class Time {
  constructor(hours, minutes) {
    this._hours = Math.abs(hours) % 24;
    this._minutes = arguments.length == 1 ? 0 : Math.abs(minutes) % 60;
  }

  static dinnerTime = new Time(18, 0);

  static getTimeDifference(t1, t2) {
    return new Time(t1.hours - t2.hours, t1.minutes - t2.minutes);
  }

  get hours() {
    return this._hours;
  }

  set hours(value) {
    this._hours = Math.abs(value) % 24;
  }

  get minutes() {
    return this._minutes;
  }

  set minutes(value) {
    this._minutes = Math.abs(value) % 60;
  }

  isDinnerTime() {
    return this.hours == Time.dinnerTime.hours && this.minutes == Time.dinnerTime.minutes;
  }
}
// Need to expose the type to the global scope.
globalThis.Time = Time;

Puedes escribir una interfaz de interoperabilidad para ella de esta manera:

dart
extension type Time._(JSObject _) implements JSObject {
  external Time(int hours, int minutes);
  external factory Time.onlyHours(int hours);

  external static Time dinnerTime;
  external static Time getTimeDifference(Time t1, Time t2);

  external int hours;
  external int minutes;
  external bool isDinnerTime();

  bool isMidnight() => hours == 0 && minutes == 0;
}

Dentro de un tipo de interoperabilidad, puedes declarar varios tipos diferentes de miembros de interoperabilidad external:

  • Constructores. Al ser llamados, los constructores con solo parámetros posicionales crean un nuevo objeto de JS cuyo constructor se define por el nombre del extension type usando new. Por ejemplo, llamar a Time(0, 0) en Dart genera una invocación de JS que se ve como new Time(0, 0). Del mismo modo, llamar a Time.onlyHours(0) genera una invocación de JS que se ve como new Time(0). Ten en cuenta que las invocaciones de JS de los dos constructores siguen las mismas semánticas, independientemente de si se les da un nombre de Dart o si son un factory.

    • Constructores de objetos literales. A veces es útil crear un objeto literal de JS que simplemente contenga un número de propiedades y sus valores. Para hacer esto, declara un constructor con solo parámetros nombrados, donde los nombres de los parámetros coincidan con los nombres de las propiedades:

      dart
      extension type Options._(JSObject o) implements JSObject {
        external Options({int a, int b});
        external int get a;
        external int get b;
      }
      

      Una llamada a Options(a: 0, b: 1) da como resultado la creación del objeto de JS {a: 0, b: 1}. El objeto se define por los argumentos de la invocación, por lo que llamar a Options(a: 0) da como resultado {a: 0}. Puedes obtener o establecer las propiedades del objeto a través de miembros de instancia external.

  • Miembros static. Al igual que los constructores, los miembros estáticos utilizan el nombre del extension type para generar el código de JS. Por ejemplo, llamar a Time.getTimeDifference(t1, t2) genera una invocación de JS que se ve como Time.getTimeDifference(t1, t2). Del mismo modo, llamar a Time.dinnerTime da como resultado una invocación de JS que se ve como Time.dinnerTime. Al igual que con los de nivel superior, puedes declarar métodos, getters, setters y campos static.

  • Miembros de instancia. Al igual que con otros tipos de Dart, los miembros de instancia requieren una instancia para ser utilizados. Estos miembros obtienen, establecen o invocan propiedades en la instancia. Por ejemplo:

    dart
      final time = Time(0, 0);
      print(time.isDinnerTime()); // false
      final dinnerTime = Time.dinnerTime;
      time.hours = dinnerTime.hours;
      time.minutes = dinnerTime.minutes;
      print(time.isDinnerTime()); // true
    

    La llamada a dinnerTime.hours obtiene el valor de la propiedad hours de dinnerTime. Del mismo modo, la llamada a time.minutes= establece el valor de la propiedad minutes de time. La llamada a time.isDinnerTime() llama a la función en la propiedad isDinnerTime de time y devuelve el valor. Al igual que con los miembros de nivel superior y static, puedes declarar métodos, getters, setters y campos de instancia.

  • Operadores. Solo se permiten dos operadores de interoperabilidad external en los tipos de interoperabilidad: [] y []=. Estos son miembros de instancia que coinciden con la semántica de los accesores de propiedades (property accessors) de JS. Por ejemplo, puedes declararlos de esta manera:

    dart
    extension type Array(JSArray<JSNumber> _) implements JSArray<JSNumber> {
      external JSNumber operator [](int index);
      external void operator []=(int index, JSNumber value);
    }
    

    Llamar a array[i] obtiene el valor en la posición i-ésima de array, y array[i] = i.toJS establece el valor en esa posición a i.toJS. Otros operadores de JS se exponen mediante funciones de utilidad en dart:js_interop.

Por último, al igual que cualquier otro extension type, tienes permitido declarar cualquier miembro no external en el tipo de interoperabilidad. Un getter booleano isMidnight que utiliza los valores de interoperabilidad es un ejemplo de ello.

Miembros de extensión en tipos de interoperabilidad

#

También puedes escribir miembros external en extensiones (extensions) de tipos de interoperabilidad. Por ejemplo:

dart
extension on Array {
  external int push(JSAny? any);
}

La semántica de llamar a push es idéntica a lo que habría sido si estuviera en la definición de Array en su lugar. Las extensiones pueden tener miembros y operadores de instancia external, pero no pueden tener miembros estáticos (static) o constructores external. Al igual que con los tipos de interoperabilidad, puedes escribir cualquier miembro no external en la extensión. Estas extensiones son útiles para cuando un tipo de interoperabilidad no expone el miembro external que necesitas y no deseas crear un nuevo tipo de interoperabilidad.

Parámetros

#

Los métodos de interoperabilidad external solo pueden contener argumentos posicionales y opcionales. Esto se debe a que los miembros de JS solo admiten argumentos posicionales. La única excepción son los constructores de objetos literales, donde pueden contener únicamente argumentos nombrados.

A diferencia de los métodos no external, los argumentos opcionales no se reemplazan con su valor predeterminado, sino que se omiten. Por ejemplo:

dart
external int push(JSAny? any, [JSAny? any2]);

Llamar a array.push(0.toJS) en Dart da como resultado una invocación de JS de array.push(0.toJS) y no array.push(0.toJS, null). Esto permite a los usuarios no tener que escribir múltiples miembros de interoperabilidad para la misma API de JS para evitar pasar nulls. Si declaras un parámetro con un valor predeterminado explícito, obtendrás una advertencia de que el valor será ignorado.

@JS()

#

A veces es útil referirse a una propiedad de JS con un nombre diferente al escrito. Por ejemplo, si deseas escribir dos APIs external que apunten a la misma propiedad de JS, necesitarías escribir un nombre diferente para al menos una de ellas. Del mismo modo, si deseas definir múltiples tipos de interoperabilidad que se refieran a la misma interfaz de JS, necesitas renombrar al menos uno de ellos. Otro ejemplo es si el nombre de JS no se puede escribir en Dart, como por ejemplo $a.

Para hacer esto, puedes usar la anotación @JS() con un valor de cadena constante. Por ejemplo:

dart
extension type Array._(JSArray<JSAny?> _) implements JSArray<JSAny?> {
  external int push(JSNumber number);
  @JS('push')
  external int pushString(JSString string);
}

Llamar a push o pushString da como resultado un código JS que utiliza push.

También puedes renombrar tipos de interoperabilidad:

dart
@JS('Date')
extension type JSDate._(JSObject _) implements JSObject {
  external JSDate();

  external static int now();
}

Llamar a JSDate() da como resultado una invocación de JS de new Date(). Del mismo modo, llamar a JSDate.now() da como resultado una invocación de JS de Date.now().

Además, puedes definir un espacio de nombres (namespace) para toda una librería, agregando un prefijo a todos los miembros de interoperabilidad de nivel superior, tipos de interoperabilidad y miembros de interoperabilidad static dentro de esos tipos. Esto es útil si deseas evitar agregar demasiados miembros al alcance global de JS.

dart
@JS('library1')
library;

import 'dart:js_interop';

@JS()
external void method();

extension type JSType._(JSObject _) implements JSObject {
  external JSType();

  external static int get staticMember;
}

Llamar a method() da como resultado una invocación de JS de library1.method(), llamar a JSType() da como resultado una invocación de JS de new library1.JSType(), y llamar a JSType.staticMember da como resultado una invocación de JS de library1.JSType.staticMember.

A diferencia de los miembros y tipos de interoperabilidad, Dart solo agrega el nombre de una librería en la invocación de JS si proporcionas un valor no vacío en la anotación @JS() de la librería. No utiliza el nombre de Dart de la librería por defecto.

dart
library interop_library;

import 'dart:js_interop';

@JS()
external void method();

Llamar a method() da como resultado una invocación de JS de method() y no de interop_library.method().

También puedes escribir múltiples espacios de nombres delimitados por un . para librerías, miembros de nivel superior y tipos de interoperabilidad:

dart
@JS('library1.library2')
library;

import 'dart:js_interop';

@JS('library3.method')
external void method();

@JS('library3.JSType')
extension type JSType._(JSObject _) implements JSObject {
  external JSType();
}

Llamar a method() da como resultado una invocación de JS de library1.library2.library3.method(), llamar a JSType() da como resultado una invocación de JS de new library1.library2.library3.JSType(), y así sucesivamente.

Sin embargo, no puedes usar anotaciones @JS() con . en el valor de los miembros de tipos de interoperabilidad o miembros de extensión de tipos de interoperabilidad.

Si no se proporciona ningún valor a @JS() o el valor está vacío, no se produce ningún renombramiento.

@JS() también le indica al compilador que un miembro o tipo está destinado a ser tratado como un miembro o tipo de JS interop. Es obligatorio (con o sin un valor) para todos los miembros de nivel superior para distinguirlos de otros miembros de nivel superior external, pero a menudo se puede eludir en y dentro de los tipos de interoperabilidad y en los miembros de extensión, ya que el compilador puede deducir que es un tipo de JS interop a partir de su tipo de representación y del tipo de destino (on-type).

Exporta funciones y objetos de Dart a JS

#

Las secciones anteriores muestran cómo llamar a miembros de JS desde Dart. También es útil exportar código Dart para que pueda usarse en JS. Para exportar una función de Dart a JS, primero conviértela usando Function.toJS, lo cual envuelve la función de Dart en una función de JS. Luego, pasa la función envuelta a JS a través de un miembro de interoperabilidad. En ese punto, estará lista para ser llamada por otro código de JS.

Por ejemplo, este código convierte una función de Dart y utiliza la interoperabilidad para establecerla en una propiedad global, que luego se llama en JS:

dart
import 'dart:js_interop';

@JS()
external set exportedFunction(JSFunction value);

void printString(JSString string) {
  print(string.toDart);
}

void main() {
  exportedFunction = printString.toJS;
}
js
globalThis.exportedFunction('hello world');

Las funciones que se exportan de esta manera tienen restricciones de tipo similares a las de los miembros de interoperabilidad.

A veces es útil exportar una interfaz de Dart completa para que JS pueda interactuar con un objeto de Dart. Para hacer esto, marca la clase de Dart como exportable usando @JSExport y envuelve las instancias de esa clase utilizando createJSInteropWrapper. Para obtener una explicación más detallada de esta técnica, incluyendo cómo simular (mock) valores de JS, consulta Cómo simular (mock) objetos de interoperabilidad con JavaScript.

dart:js_interop y dart:js_interop_unsafe

#

La librería dart:js_interop contiene todos los miembros necesarios que deberías requerir, incluyendo @JS, tipos de JS, funciones de conversión y varias funciones de utilidad. Las funciones de utilidad incluyen:

  • globalContext, que representa el alcance global que utilizan los compiladores para encontrar miembros y tipos de interoperabilidad.
  • Ayudantes para inspeccionar el tipo de los valores de JS
  • Operadores de JS
  • dartify y jsify, que comprueban el tipo de ciertos valores de JS y los convierten a valores de Dart y viceversa. Prefiere usar la conversión específica cuando conozcas el tipo del valor de JS, ya que la comprobación de tipo adicional puede ser costosa.
  • importModule, que te permite importar módulos dinámicamente como un JSObject.
  • isA, que te permite comprobar si un valor de JS-interop es una instancia del tipo de JS especificado por el argumento de tipo.

Es posible que se agreguen más utilidades a esta librería en el futuro.

La librería dart:js_interop_unsafe contiene miembros que te permiten buscar propiedades de forma dinámica. Por ejemplo:

dart
JSFunction f = console['log'];

En lugar de declarar un miembro de interoperabilidad llamado log, se puede usar una cadena (String) para acceder a la propiedad. dart:js_interop_unsafe también proporciona funciones para comprobar, obtener, establecer y llamar propiedades dinámicamente.