JS interop 사용법
JS interop 사용법
JS interop 멤버를 선언하고 사용하는 방법을 배워 봐요.
출처: 원문
본문
JS interop은 Dart에서 JavaScript API와 상호작용할 수 있는 메커니즘을 제공해요. 이 메커니즘을 사용하면 명시적이고 관용적인 문법으로 이 API를 호출하고, 그로부터 얻은 값과 상호작용할 수 있어요.
보통은 JavaScript API를 전역 JS 스코프의 어딘가에서 사용할 수 있게 만드는 방식으로 접근해요. 이 API에서 JS 값을 호출하고 받으려면 external interop 멤버를 사용해요. JS 값을 구성하고 타입을 제공하려면 interop 타입을 사용하고 선언하며, interop 타입도 interop 멤버를 담아요. List나 Function 같은 Dart 값을 interop 멤버에 전달하거나 JS 값을 Dart 값으로 변환하려면, interop 멤버가 원시(primitive) 타입을 담고 있지 않다면 변환 함수를 사용해요.
Interop 타입
JS 값과 상호작용할 때는 그 값에 Dart 타입을 제공해야 해요. 이는 interop 타입을 사용하거나 선언하는 방식으로 할 수 있어요. Interop 타입은 Dart가 제공하는 "JS 타입"이거나, interop 타입을 감싸는 extension type이에요.
Interop 타입은 JS 값에 인터페이스를 제공하고 그 멤버에 대한 interop API를 선언할 수 있게 해 줘요. 또한 다른 interop API의 시그니처에서도 사용돼요.
extension type Window(JSObject _) implements JSObject {}
Window는 임의의 JSObject를 위한 interop 타입이에요. Window가 실제로 JS Window라는 런타임 보장은 없어요. 같은 값에 대해 정의된 다른 interop 인터페이스와 충돌하는 일도 없어요. Window가 실제로 JS Window인지 확인하고 싶다면 interop을 통해 JS 값의 타입을 확인할 수 있어요.
또한 Dart가 제공하는 JS 타입을 감싸서 자신만의 interop 타입을 선언할 수도 있어요:
extension type Array._(JSArray<JSAny?> _) implements JSArray<JSAny?> {
external Array();
}
대부분의 경우, Dart가 interop 타입을 제공하지 않는 JS 객체와 상호작용하기 때문에 JSObject를 표현 타입으로 사용해 interop 타입을 선언하게 될 거예요.
Interop 타입은 일반적으로 자신의 표현 타입을 implement해서, package:web이 제공하는 많은 API처럼 표현 타입이 기대되는 곳에서 사용될 수 있게 해야 해요.
Interop 멤버
external interop 멤버는 JS 멤버에 대한 관용적인 문법을 제공해요. 인자와 반환 값에 Dart 타입 시그니처를 작성할 수 있게 해 줘요. 이 멤버들의 시그니처에 쓸 수 있는 타입에는 제한이 있어요. Interop 멤버가 대응하는 JS API는 선언된 위치, 이름, 어떤 종류의 Dart 멤버인지, 그리고 어떤 rename이 있는지의 조합으로 결정돼요.
최상위 interop 멤버
다음 JS 멤버가 있다고 해 볼게요:
globalThis.name = 'global';
globalThis.isNameEmpty = function() {
return globalThis.name.length == 0;
}
이에 대한 interop 멤버를 이렇게 작성할 수 있어요:
@JS()
external String get name;
@JS()
external set name(String value);
@JS()
external bool isNameEmpty();
여기서 name이라는 프로퍼티와 isNameEmpty라는 함수가 전역 스코프에 노출되어 있어요. 그것들에 접근하려면 최상위 interop 멤버를 사용해요. name을 읽고 쓰려면 같은 이름의 interop getter와 setter를 선언하고 사용해요. isNameEmpty를 사용하려면 같은 이름의 interop 함수를 선언하고 호출해요. 최상위 interop getter, setter, 메서드, 필드를 선언할 수 있어요. Interop 필드는 getter와 setter 쌍과 동일해요.
최상위 interop 멤버는 dart:ffi로 작성할 수 있는 것 같은 다른 external 최상위 멤버와 구분하기 위해 @JS() 어노테이션으로 선언해야 해요.
Interop 타입 멤버
다음 같은 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;
이에 대한 interop 인터페이스를 이렇게 작성할 수 있어요:
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;
}
Interop 타입 안에서는 여러 종류의 external interop 멤버를 선언할 수 있어요:
-
생성자(Constructors). 호출되면 위치 매개변수만 있는 생성자는 extension type의 이름으로 정의된 생성자를
new로 사용해 새 JS 객체를 만들어요. 예를 들어 Dart에서Time(0, 0)을 호출하면new Time(0, 0)처럼 보이는 JS 호출이 생성돼요. 마찬가지로Time.onlyHours(0)을 호출하면new Time(0)처럼 보이는 JS 호출이 생성돼요. 두 생성자의 JS 호출은, Dart 이름이 주어졌든 factory든, 같은 시맨틱을 따른다는 점을 기억하세요. -
객체 리터럴 생성자(Object literal constructors). 특정 수의 프로퍼티와 그 값만 담고 있는 JS 객체 리터럴을 만드는 게 유용할 때가 있어요. 이를 위해 이름 매개변수만 있는 생성자를 선언하면서, 매개변수의 이름이 프로퍼티 이름과 일치하게 만들면 돼요:
extension type Options._(JSObject o) implements JSObject {
external Options({int a, int b});
external int get a;
external int get b;
}
Options(a: 0, b: 1) 호출은 JS 객체 {a: 0, b: 1}을 만들어요. 객체는 호출 인자로 정의되므로 Options(a: 0)을 호출하면 {a: 0}이 돼요. 객체의 프로퍼티는 external 인스턴스 멤버로 읽거나 쓸 수 있어요.
⚠️ 경고: Dart 3.3.1 이전에는 객체 리터럴 생성자가 컴파일되려면 라이브러리에
@JS어노테이션이 필요했어요. 더 자세한 내용은dart-lang/sdk#54801을 참고하세요.
-
static멤버. 생성자와 마찬가지로 static 멤버는 JS 코드를 생성할 때 extension type의 이름을 사용해요. 예를 들어Time.getTimeDifference(t1, t2)을 호출하면Time.getTimeDifference(t1, t2)처럼 보이는 JS 호출이 생성돼요. 마찬가지로Time.dinnerTime을 호출하면Time.dinnerTime처럼 보이는 JS 호출이 돼요. 최상위 멤버와 마찬가지로static메서드, getter, setter, 필드를 선언할 수 있어요. -
인스턴스 멤버. 다른 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
dinnerTime.hours 호출은 dinnerTime의 hours 프로퍼티 값을 가져와요. 마찬가지로 time.minutes= 호출은 time의 minutes 프로퍼티 값을 설정해요. time.isDinnerTime() 호출은 time의 isDinnerTime 프로퍼티의 함수를 호출하고 그 값을 반환해요. 최상위 멤버와 static 멤버처럼 인스턴스 메서드, getter, setter, 필드를 선언할 수 있어요.
- 연산자(Operators). Interop 타입에서 허용되는
externalinterop 연산자는[]와[]=두 개뿐이에요. 이들은 JS의 프로퍼티 접근자 시맨틱과 일치하는 인스턴스 멤버예요. 예를 들어 이렇게 선언할 수 있어요:
extension type Array(JSArray<JSNumber> _) implements JSArray<JSNumber> {
external JSNumber operator [](int index);
external void operator []=(int index, JSNumber value);
}
array[i] 호출은 array의 i번째 슬롯의 값을 가져오고, array[i] = i.toJS는 그 슬롯의 값을 i.toJS로 설정해요. 다른 JS 연산자들은 dart:js_interop의 유틸리티 함수로 노출돼요.
마지막으로, 다른 extension type과 마찬가지로 interop 타입 안에 external이 아닌 멤버를 자유롭게 선언할 수 있어요. interop 값을 사용하는 boolean getter isMidnight가 그런 예시예요.
Interop 타입의 확장 멤버
Interop 타입의 확장(extension)에 external 멤버를 작성할 수도 있어요. 예를 들면:
extension on Array {
external int push(JSAny? any);
}
push 호출의 시맨틱은 그것이 Array 정의 안에 있었을 때와 동일해요. Extension은 external 인스턴스 멤버와 연산자를 가질 수 있지만, external static 멤버나 생성자는 가질 수 없어요. Interop 타입과 마찬가지로 extension에 external이 아닌 멤버를 작성할 수 있어요. 이런 extension은 interop 타입이 필요한 external 멤버를 노출하지 않는데 새 interop 타입을 만들고 싶지 않을 때 유용해요.
매개변수
external interop 메서드는 위치 인자와 선택 인자만 담을 수 있어요. JS 멤버는 위치 인자만 받기 때문이에요. 유일한 예외는 객체 리터럴 생성자인데, 이들은 이름 인자만 담을 수 있어요.
external이 아닌 메서드와 달리, 선택 인자는 기본값으로 대체되지 않고 대신 생략돼요. 예를 들면:
external int push(JSAny? any, [JSAny? any2]);
Dart에서 array.push(0.toJS)를 호출하면 array.push(0.toJS)의 JS 호출이 되지 array.push(0.toJS, null)이 되지 않아요. 이렇게 하면 사용자가 null을 넘기지 않기 위해 같은 JS API에 여러 interop 멤버를 작성하지 않아도 돼요. 명시적 기본값을 가진 매개변수를 선언하면 그 값이 무시된다는 경고를 받게 돼요.
@JS()
때로는 작성된 이름과 다른 이름으로 JS 프로퍼티를 참조하는 게 유용할 때가 있어요. 예를 들어 같은 JS 프로퍼티를 가리키는 external API를 두 개 작성하려면, 적어도 하나는 다른 이름을 써야 해요. 마찬가지로 같은 JS 인터페이스를 참조하는 interop 타입을 여러 개 정의하려면 적어도 하나는 rename해야 해요. 또 다른 예로 $a처럼 형태가 Dart에서 작성될 수 없는 JS 이름이 있을 때가 있어요.
이를 위해 상수 문자열 값을 가진 @JS() 어노테이션을 사용할 수 있어요. 예를 들면:
extension type Array._(JSArray<JSAny?> _) implements JSArray<JSAny?> {
external int push(JSNumber number);
@JS('push')
external int pushString(JSString string);
}
push든 pushString이든 호출하면 push를 사용하는 JS 코드가 돼요.
Interop 타입도 rename할 수 있어요:
@JS('Date')
extension type JSDate._(JSObject _) implements JSObject {
external JSDate();
external static int now();
}
JSDate()를 호출하면 new Date()의 JS 호출이 돼요. 마찬가지로 JSDate.now() 호출은 Date.now()의 JS 호출이 돼요.
나아가 전체 라이브러리를 namespace로 묶어, 모든 interop 최상위 멤버, interop 타입, 그리고 그 타입 안의 static interop 멤버에 프리픽스를 추가할 수 있어요. 이는 전역 JS 스코프에 멤버를 너무 많이 추가하고 싶지 않을 때 유용해요.
@JS('library1')
library;
import 'dart:js_interop';
@JS()
external void method();
extension type JSType._(JSObject _) implements JSObject {
external JSType();
external static int get staticMember;
}
method()를 호출하면 library1.method()의 JS 호출이 되고, JSType()을 호출하면 new library1.JSType()의 JS 호출이 되며, JSType.staticMember를 호출하면 library1.JSType.staticMember의 JS 호출이 돼요.
Interop 멤버와 interop 타입과 달리, Dart는 라이브러리의 @JS() 어노테이션에 비어 있지 않은 값을 제공한 경우에만 JS 호출에 라이브러리 이름을 추가해요. Dart 라이브러리 이름을 기본값으로 사용하지 않아요.
library interop_library;
import 'dart:js_interop';
@JS()
external void method();
method()를 호출하면 method()의 JS 호출이 되지 interop_library.method()가 되지 않아요.
라이브러리, 최상위 멤버, interop 타입에 대해 .로 구분된 여러 namespace를 작성할 수도 있어요:
@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();
}
method()를 호출하면 library1.library2.library3.method()의 JS 호출이 되고, JSType()을 호출하면 new library1.library2.library3.JSType()의 JS 호출이 되는 식이에요.
그러나 interop 타입 멤버나 interop 타입의 확장 멤버에는 값에 .이 들어간 @JS() 어노테이션을 사용할 수 없어요.
@JS()에 제공된 값이 없거나 값이 비어 있으면 rename이 일어나지 않아요.
@JS()는 또한 멤버나 타입이 JS interop 멤버 또는 타입으로 취급되도록 의도됐다고 컴파일러에 알려줘요. 모든 최상위 멤버에 대해(@JS()만 있든 값이 있든) 다른 external 최상위 멤버와 구분하기 위해 필수적이지만, interop 타입 안의 멤버와 extension 멤버에서는 컴파일러가 표현 타입과 on-type으로 JS interop 타입임을 알아낼 수 있어서 종종 생략할 수 있어요.
Dart 함수와 객체를 JS로 export하기
앞의 섹션들은 Dart에서 JS 멤버를 호출하는 방법을 보여 줬어요. Dart 코드를 export해서 JS에서 사용하게 만드는 것도 유용해요. Dart 함수를 JS로 export하려면 먼저 Dart 함수를 JS 함수로 감싸는 Function.toJS로 변환해요. 그다음 감싼 함수를 interop 멤버를 통해 JS에 전달해요. 그러면 다른 JS 코드가 호출할 준비가 돼요.
예를 들어 이 코드는 Dart 함수를 변환하고 interop을 사용해 전역 프로퍼티에 설정한 뒤, JS에서 호출해요:
import 'dart:js_interop';
@JS()
external set exportedFunction(JSFunction value);
void printString(JSString string) {
print(string.toDart);
}
void main() {
exportedFunction = printString.toJS;
}
globalThis.exportedFunction('hello world');
이렇게 export된 함수는 interop 멤버와 비슷한 타입 제한이 있어요.
때로는 JS가 Dart 객체와 상호작용할 수 있도록 Dart 인터페이스 전체를 export하는 게 유용해요. 이를 위해 @JSExport로 Dart 클래스를 export 가능하게 표시하고, createJSInteropWrapper로 그 클래스의 인스턴스를 감싸요. 이 기법에 대한 더 자세한 설명과 JS 값을 mock하는 방법은 "How to mock JavaScript interop objects"를 참고하세요.
dart:js_interop과 dart:js_interop_unsafe
dart:js_interop은 @JS, JS 타입, 변환 함수, 여러 유틸리티 함수를 포함해 필요할 수 있는 모든 멤버를 담고 있어요. 유틸리티 함수에는 다음이 있어요:
globalContext— 컴파일러가 interop 멤버와 타입을 찾는 데 사용하는 전역 스코프를 나타내요.- JS 값의 타입을 검사하는 헬퍼.
- JS 연산자.
dartify와jsify— 특정 JS 값의 타입을 확인하고 그것들을 Dart 값으로(또는 그 반대로) 변환해요. JS 값의 타입을 알 때는 특정 변환을 사용하는 걸 선호해요. 추가 타입 검사가 비쌀 수 있기 때문이에요.importModule— 모듈을JSObject로 동적으로 import할 수 있게 해 줘요.isA— JS-interop 값이 타입 인자로 지정된 JS 타입의 인스턴스인지 확인할 수 있게 해 줘요.
앞으로 이 라이브러리에 더 많은 유틸리티가 추가될 수 있어요.
dart:js_interop_unsafe는 프로퍼티를 동적으로 조회할 수 있게 해 주는 멤버를 담고 있어요. 예를 들면:
JSFunction f = console['log'];
log라는 interop 멤버를 선언하는 대신 문자열로 프로퍼티에 접근할 수 있어요. dart:js_interop_unsafe는 프로퍼티를 동적으로 확인하고, 가져오고, 설정하고, 호출하는 함수도 제공해요.
💡 팁: 가능하면
dart:js_interop_unsafe를 사용하지 마세요. 보안 준수를 보장하기가 더 어려워지고 위반으로 이어질 수 있어서, 그래서 "unsafe"라고 불려요.
더 알아보기
- JS interop — JS interop의 개요와 시작 방법을 살펴보세요.
- JS 타입 — Dart가 제공하는 JS 타입을 알아보세요.
- dart:js_interop —
dart:js_interop라이브러리 레퍼런스예요.