package:web로 마이그레이션하기
package:web로 마이그레이션하기
웹 interop 코드를 dart:html에서 package:web로 마이그레이션하는 방법이에요.
본문
Dart의 package:web는 브라우저 API에 대한 접근을 노출해서, Dart 애플리케이션과 웹 사이의 interop을 가능하게 해 줘요. package:web를 사용해서 브라우저와 상호 작용하고 DOM의 객체와 요소를 조작할 수 있어요.
import 'package:web/web.dart';
void main() {
final div = document.querySelector('div')!;
div.textContent = 'Text set at ${DateTime.now()}';
}
package:web vs dart:html
package:web의 목표는 기존 Dart 웹 라이브러리의 여러 문제를 해결해서 Dart가 웹 API를 노출하는 방식을 쇄신하는 거예요:
- Wasm 호환성: 패키지가 Wasm과 호환되려면
dart:js_interop과dart:js_interop_unsafe를 사용해야만 해요.package:web는dart:js_interop에 기반을 두므로 기본적으로dart2wasm에서 지원돼요.dart:html이나dart:svg같은 Dart 핵심 웹 라이브러리는 deprecated이며 Wasm으로 컴파일할 때 지원되지 않아요. - 최신 상태 유지:
package:web는 Web IDL을 사용하여 IDL의 각 선언에 대한 interop 멤버와 interop 타입을 자동으로 생성해요.dart:html의 추가 멤버와 추상화 대신 참조를 직접 생성함으로써,package:web는 더 간결하고 이해하기 쉬우며 일관되고, 웹 개발의 미래를 더 잘 따라갈 수 있어요. - 버전 관리: 패키지이므로
package:web는dart:html같은 라이브러리보다 더 쉽게 버전을 관리할 수 있고, 진화하면서 사용자 코드를 깨뜨리는 것을 피할 수 있어요. 또한 코드를 덜 폐쇄적으로 만들고 기여를 더 쉽게 받을 수 있게 해 줘요. 개발자는 자신만의 대체 interop 선언을 만들어서package:web와 충돌 없이 함께 사용할 수 있어요.
이런 개선들은 자연스럽게 package:web와 dart:html 사이에 몇 가지 구현 차이로 이어져요. IDL rename이나 타입 테스트처럼 기존 패키지에 가장 큰 영향을 주는 변경 사항은 다음의 마이그레이션 섹션에서 다룰게요. 간결함을 위해 dart:html만 언급하지만, 동일한 마이그레이션 패턴은 dart:svg 같은 다른 Dart 핵심 웹 라이브러리에도 적용돼요.
dart:html에서 마이그레이션하기
dart:html import를 제거하고 package:web/web.dart로 교체해요:
import 'dart:html' as html; // 제거
import 'package:web/web.dart' as web; // 추가
pubspec의 dependencies에 web을 추가해요:
dart pub add web
다음 섹션들에서는 dart:html에서 package:web로 옮길 때 흔히 겪는 마이그레이션 문제 몇 가지를 다룰게요.
다른 마이그레이션 문제가 있다면 dart-lang/web 저장소를 확인하고 이슈를 등록해요.
이름 변경 (Renames)
dart:html의 많은 심볼들은 Dart 스타일에 더 맞도록 원래 IDL 선언에서 이름이 바뀌었어요. 예를 들어 appendChild는 append가 되었고, HTMLElement는 HtmlElement가 되었죠.
반대로 혼란을 줄이기 위해 package:web는 IDL 정의의 원래 이름을 사용해요. dart:html과 package:web 사이에서 이름이 바뀐 타입을 변환해 주는 dart fix가 제공돼요.
import를 바꾼 뒤에는 이름이 바뀐 객체들이 새로운 "undefined" 오류가 돼요. 이는 다음 두 가지 방법으로 해결할 수 있어요:
- CLI에서
dart fix --dry-run을 실행해서 - IDE에서
dart fix:package:web이름으로 Rename을 선택해서
dart fix는 흔한 타입 이름 변경의 상당수를 처리해 줘요. dart fix로 이름을 바꿀 수 없는 dart:html 타입을 발견하면 먼저 이슈를 등록해서 알려줘요.
그런 다음, 기존 dart:html 멤버의 package:web 타입 이름을 수동으로 찾으려면 그 정의를 조회해 볼 수 있어요. dart:html 멤버 정의의 @Native 어노테이션 값은 컴파일러에게 해당 타입의 어떤 JS 객체든 그 어노테이션이 붙은 Dart 클래스로 취급하라고 알려 줘요. 예를 들어 @Native 어노테이션은 dart:html의 HtmlElement 멤버의 네이티브 JS 이름이 HTMLElement임을 알려 주므로, package:web 이름도 HTMLElement가 돼요:
@Native("HTMLElement")
class HtmlElement extends Element implements NoncedElement { }
package:web에서 undefined인 멤버의 dart:html 정의를 찾으려면 다음 두 방법 중 하나를 시도해 보세요:
- IDE에서 undefined 이름을 Ctrl 또는 command 클릭하고 Go to Definition을 선택해요.
dart:htmlAPI 문서에서 이름을 검색하고 Annotations 아래 페이지를 확인해요.
비슷하게, 대응하는 dart:html 멤버의 정의가 native 키워드를 쓰는 undefined package:web API를 찾을 수도 있어요. 정의가 rename을 위해 @JSName 어노테이션을 사용하는지 확인해요. 어노테이션의 값이 그 멤버가 package:web에서 사용하는 이름을 알려 줘요:
@JSName('appendChild')
Node append(Node node) native;
native는 이 맥락에서 external과 같은 의미인 내부 키워드예요.
Element와 HTMLElement
Element는 이제 HTMLElement이지만, extension type Element도 HTMLElement, SVGElement 등의 기본 타입으로 존재해요. Element MDN 문서를 참고해요. querySelector는 HTMLElement 외에 SVGElement 같은 Element의 다른 하위 타입을 반환할 수 있으므로 Element를 반환해요. 그 메서드에 접근해야 한다면 HTMLElement로 다운캐스트가 필요해요.
element.querySelector('#selectme')!.className = 'test'; // 둘 다에서 유효
element.querySelector('#selectme')!.style.color = 'red'; // 제거
(element.querySelector('#selectme') as HTMLElement).style.color = 'red'; // 추가
리스트 연산
dart:html과 달리, Element.querySelectorAll, Element.children 같은 package:web 메서드는 List를 구현하지 않는 값을 반환해요. List가 필요하다면 클래스로 감싸야 해요.
불변 연산에는 JSImmutableListWrapper를 사용할 수 있어요:
final anchors = document.querySelectorAll('a');
for (final anchor in anchors) {} // 제거
for (final anchor in JSImmutableListWrapper(anchors)) {} // 추가
for (final child in parent.children) {} // 제거
for (final child in JSImmutableListWrapper(parent.children)) {} // 추가
removeWhere 같은 간단한 가변 연산에는 구현을 직접 추가해야 해요:
parent.children.removeWhere(test); // 제거
for (var i = parent.children.length - 1; i >= 0; --i) {
if (test(parent.children.item(i)!)) {
parent.children.item(i)!.remove();
}
} // 추가
흔한 DOM 조작 예제
dart:html에서 package:web로 마이그레이션할 때 해야 하는 단순한 변경 사항들의 흔한 예제는 다음과 같아요:
element.querySelector('#selector')?.innerHtml = 'something'; // 제거
element.querySelector('#selector')?.innerHTML = 'something'.toJS; // 추가
element.parent.classes.add('class'); // 제거
element.parentElement.classList.add('class'); // 추가
element.appendHtml(html); // 제거
element.insertAdjacentHTML('beforeend', html.toJS); // 추가
var checkbox = CheckboxInputElement(); // 제거
var checkbox = HTMLInputElement()..type='checkbox'; // 추가
element.text = 'Hello'; // 제거
element.textContent = 'Hello'; // 추가
element.querySelectorAll('a').classes.add('link'); // 제거
for (final a in JSImmutableListWrapper(element.querySelectorAll('a'))) {
a.classList.add('a');
} // 추가
타입 테스트
dart:html을 사용하는 코드가 is 같은 런타임 검사를 활용하는 것은 흔한 일이에요. dart:html 객체와 사용될 때, is와 as는 그 객체가 @Native 어노테이션 안의 JS 타입인지 검증해요. 반면 모든 package:web 타입은 JSObject로 구현되므로, 런타임 타입 테스트는 dart:html 타입과 package:web 타입 사이에서 다른 동작을 하게 돼요.
타입 테스트를 수행하려면, is 타입 테스트를 사용하는 dart:html 코드를 instanceOfString 같은 interop 메서드나 더 편리하고 타입이 있는 isA 헬퍼(Dart 3.4부터 사용 가능)로 마이그레이션해요. JS 타입 페이지의 "호환성, 타입 검사, 캐스트" 섹션에서 대안을 자세히 다루고 있어요.
obj is Window; // 제거
obj.instanceOfString('Window'); // 추가
element is InputElement; // 제거
element.isA<HTMLInputElement>(); // 추가
타입 시그니처
dart:html의 많은 API는 타입 시그니처에서 다양한 Dart 타입을 지원해요. dart:js_interop이 작성할 수 있는 타입을 제한하므로, package:web의 일부 멤버는 이제 멤버를 호출하기 전에 값을 변환하도록 요구할 거예요. interop 변환 메서드 사용법은 JS 타입 페이지의 "변환" 섹션에서 배울 수 있어요.
예를 들어 addEventListener에 콜백을 전달할 때, Dart 함수를 .toJS로 JSFunction으로 변환해요:
window.addEventListener('click', callback); // 제거
window.addEventListener('click', callback.toJS); // 추가
// 여기서 callback의 첫 번째 파라미터는 Event를 받아요.
addEventListener 대신 Dart Stream으로 DOM 이벤트를 구독하는 걸 선호한다면, package:web의 stream 헬퍼 extension을 사용할 수 있어요:
// stream 헬퍼 extension 사용 (dart:html과 같은 API 형태):
button.onClick.listen((event) {
// event는 캐스트 없이 이미 MouseEvent로 타입이 정해져 있어요:
print('Clicked at (${event.clientX}, ${event.clientY})');
});
// 또는 EventStreamProviders를 직접 사용:
EventStreamProviders.clickEvent.forTarget(button).listen((event) {
print('Clicked at (${event.clientX}, ${event.clientY})');
});
일반적으로 어떤 메서드에 변환이 필요한지는 그 메서드가 다음과 같은 예외의 변형으로 표시되는지 보고 알아챌 수 있어요:
A value of type '...' can't be assigned to a variable of type 'JSFunction?'
조건부 import
코드가 dart:html이 지원되는지에 따라 조건부 import를 사용해서 네이티브와 웹을 구분하는 것은 흔한 일이에요:
export 'src/hw_none.dart'
if (dart.library.io) 'src/hw_io.dart'
if (dart.library.html) 'src/hw_html.dart';
하지만 dart:html은 deprecated이고 Wasm으로 컴파일할 때 지원되지 않으므로, 이제 올바른 대안은 dart.library.js_interop을 사용해서 네이티브와 웹을 구분하는 거예요:
export 'src/hw_none.dart' // 스텁 구현
if (dart.library.io) 'src/hw_io.dart' // dart:io 구현
if (dart.library.js_interop) 'src/hw_web.dart'; // package:web 구현
가상 디스패치와 모의
dart:html 클래스는 가상 디스패치(virtual dispatch)를 지원했지만, JS interop은 extension type을 사용하므로 가상 디스패치는 불가능해요. 마찬가지로 package:web 타입을 사용한 dynamic 호출은 기대대로 동작하지 않아요(또는 우연히 계속 동작할 수도 있지만 dart:html이 제거되면 멈출 거예요). 그 멤버들은 정적으로만 사용할 수 있기 때문이에요. 이 문제를 피하려면 가상 디스패치에 의존하는 모든 코드를 마이그레이션해요.
가상 디스패치의 한 사용 사례는 모의(mocking)예요. dart:html 클래스를 implements하는 모의 클래스가 있다면, 그건 package:web 타입을 구현하는 데 사용할 수 없어요. 대신 JS 객체 자체를 모의하는 걸 선호해요. 자세한 내용은 mocking 튜토리얼을 참고해요.
네이티브가 아닌 API
dart:html 클래스에는 사소하지 않은 구현을 가진 API가 들어 있을 수도 있어요. 이런 멤버들은 package:web 헬퍼에 존재할 수도 있고 없을 수도 있어요. 코드가 그 구현의 세부 사항에 의존한다면, 필요한 코드를 복사할 수도 있어요. 하지만 그것이 다루기 어렵거나 그 코드가 다른 사용자에게도 유용할 것 같다면, 이슈를 등록하거나 package:web에 그 멤버를 지원하는 pull request를 올리는 것을 고려해 봐요.
Zones
dart:html에서는 콜백이 자동으로 zone에 묶여요. package:web에서는 그렇지 않아요. 현재 zone에 콜백이 자동으로 바인딩되지는 않아요.
이것이 애플리케이션에 중요하다면, 콜백을 직접 바인딩해서 zone을 계속 사용할 수는 있어요. 자세한 내용은 #54507을 참고해요.
예를 들어 DOM 이벤트 리스너에 콜백을 전달할 때, .toJS로 변환하기 전에 현재 zone에 수동으로 바인딩해요:
// 이전: 콜백이 zone 컨텍스트를 잃음
element.addEventListener(
'click',
(web.Event event) {
// ... zone-local 값에 의존함
}.toJS,
);
element.addEventListener(
'click',
Zone.current.bindUnaryCallback((web.Event event) {
// ... zone-local 값이 보존됨
}).toJS,
);
이를 자동으로 해 주는 변환 API나 헬퍼는 아직 없어요.
헬퍼
package:web의 핵심은 external interop 멤버를 포함하지만, dart:html이 기본으로 제공하던 다른 기능은 제공하지 않아요. 이런 차이를 완화하기 위해 package:web는 핵심 interop을 통해 직접 사용할 수 없는 여러 사용 사례를 처리하는 데 도움이 되는 helpers를 포함해요. 헬퍼 라이브러리는 Dart 웹 라이브러리의 일부 레거시 기능을 노출하는 다양한 멤버를 포함해요.
예를 들어 핵심 package:web는 이벤트 리스너의 추가와 제거만 지원해요. 대신 stream 헬퍼를 사용해서 그 코드를 직접 작성하지 않고도 Dart Stream으로 이벤트를 쉽게 구독할 수 있어요.
// 원래 dart:html 버전:
final htmlInput = InputElement();
await htmlInput.onBlur.first;
// 마이그레이션된 package:web 버전:
final webInput = HTMLInputElement();
await webInput.onBlur.first;
모든 헬퍼와 그 문서는 저장소의 package:web/helpers에서 찾을 수 있어요. 이들은 사용자의 마이그레이션을 돕고 웹 API를 더 쉽게 사용할 수 있도록 계속 업데이트될 거예요.
예제
다음은 dart:html에서 package:web로 마이그레이션된 패키지들의 예시예요:
url_launcher를package:web로 업그레이드하기