본문 바로가기

리액트 네이티브

esbuild로 리액트 네이티브에서 HMR을 구현해 보자.

 

GitHub - JoonDong2/react-native-esbuild

Contribute to JoonDong2/react-native-esbuild development by creating an account on GitHub.

github.com

 

2025/01/22 내용 수정

resolve관련하여 6-1-1, 6-2-5 수정본 추가, 고민해볼 것들 8-4, 8-5, 8-6, 8-7추가

 

1. 필요성

HMR(Hot Module Reload)의 핵심은 앱의 전체 상태를 유지하면서 변경된 부분만 빠르게 빌드하고 교체하는 것입니다.

 

개발자 경험에 있어서 아주 중요한 부분이라고 생각합니다.

 

esbuild가 아무리 빠르다고 하더라도, 번들링 이외의 기능을 거의 제공하지 않는다는 것에 많은 아쉬움을 느꼈습니다.

 

esbuild로 리액트 네이티브 앱을 번들링하는 라이브러리는 이미 몇 개 있지만, HMR은 지원하지 않거나 아직 개발중인 것으로 보입니다.

마침 vite는 개발 환경에선 esbuild를 사용하여 hmr을 구현하였기에 리액트 네이티브도 충분히 가능할 것 같았습니다.

vite는 어쨌든 esbuild로 hmr을 구현하고 있기는 하지만, 빌드할 때는 rollup을 사용합니다.
https://ko.vite.dev/guide/why#why-not-bundle-with-esbuild

마찬가지로 개발은 metro로 하고, 빌드는 esbuild로 할 수도 있겠지만, 개발 환경과 빌드 환경이 다르다는 것은 제 기준으로는 이해할 수 없습니다.

어떤 문제가 발생할지 예측조차 안되기 때문입니다.

esbuild는 Context를 이용해 기존 데이터를 캐시해 두고 매우 빠르게 rebuild하는 기능을 제공하지만, 이것은 "Cold"하기 때문에 고려하지 않았습니다. (이 경우 플러그인 캐싱까지 필요합니다.)
https://esbuild.github.io/api/#rebuild

2. 원칙

많이 부족하지만 아래 원칙은 지키면서 만들고 싶었습니다.

2- 1. 기존 코드와 설정이 바뀌면 안된다. (개방-폐쇄 원칙)

esbuild를 위해 기존 앱에 어떠한 코드가 추가되고, 실행 및 빌드 방법이 변경되어야 한다면 관리 포인트가 늘어나기 때문입니다.

2-2. 물 흐르듯이.. 하나의 흐름으로 처리해야 한다.

vxrn 코드를 잠깐 살펴봤는데, 코어 부분은 esbuild로 미리 빌드해 놓고, 변경 사항이 발생하면 swc로 빌드하고 있었습니다. (작동은 되는지 의문입니다.)

 

이외에도 매직 넘버가 너무 많이 삽입되어 있어서 코어(리액트 및 리액트 네이티브)의 버전 변경, 실행 환경(JSC/Hermes, Old/New Architecture)같은 외부 환경 변화에 문제없이 작동할 수 있을지도 모르겠습니다.

2-3. 근본적인 문제를 해결해야 한다.

경험적으로 근본적인 문제를 제거하지 않고, 주변에서 해결하려고 하면(어쩔수 없는 경우가 많지만), 코드가 복잡해지고 사이드 이팩트가 발생하는 경우가 많았습니다.

 

아래서 설명하겠지만 esbuild에는 근본적인 문제가 있었습니다.

3. esbuild vs metro 결과물

먼저 esbuild가 소스코드를 어떻게 변환하고 합치는지 확인할 필요가 있었습니다.

 

App 컴포넌트와 경로만 다르고 내용은 같은 두 개의 Hello 컴포넌트로 테스트했습니다.

// src/App.tsx
import { StyleSheet, Text, View } from 'react-native';
import Hello1 from './Hello';
import Hello2 from './components/Hello';
export default function App() {
return (
<View style={styles.container}>
<Text>App</Text>
<Hello1 />
<Hello2 />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
});
// src/Hello.tsx
import { StyleSheet, Text } from 'react-native';
const Hello = () => {
return <Text style={styles.text}>Hello</Text>;
};
const styles = StyleSheet.create({
text: {
color: 'black',
},
});
export default Hello;
// src/components/Hello.tsx
import { StyleSheet, Text } from 'react-native';
const Hello = () => {
return <Text style={styles.text}>Hello</Text>;
};
const styles = StyleSheet.create({
text: {
color: 'black',
},
});
export default Hello;
view raw app.tsx hosted with ❤ by GitHub

 

3-1. 이것을 metro는 다음과 같이 번들링합니다. 

__d(function (global, _$$_REQUIRE, _$$_IMPORT_DEFAULT, _$$_IMPORT_ALL, module, exports, _dependencyMap) {
var _interopRequireDefault = _$$_REQUIRE(_dependencyMap[0]);
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = App;
var _reactNative = _$$_REQUIRE(_dependencyMap[1]);
var _Hello = _interopRequireDefault(_$$_REQUIRE(_dependencyMap[2]));
var _Hello2 = _interopRequireDefault(_$$_REQUIRE(_dependencyMap[3]));
var _jsxRuntime = _$$_REQUIRE(_dependencyMap[4]);
function App() {
return /*#__PURE__*/ (0, _jsxRuntime.jsxs)(_reactNative.View, {
style: styles.container,
children: [ /*#__PURE__*/ (0, _jsxRuntime.jsx)(_reactNative.Text, {
children: "App"
}), /*#__PURE__*/ (0, _jsxRuntime.jsx)(_Hello.default, {}), /*#__PURE__*/ (0, _jsxRuntime.jsx)(_Hello2.default, {})]
});
}
var styles = _reactNative.StyleSheet.create({
container: {
flex: 1
}
});
}, 480, [1, 2, 481, 482, 223]);
__d(function (global, _$$_REQUIRE, _$$_IMPORT_DEFAULT, _$$_IMPORT_ALL, module, exports, _dependencyMap) {
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = undefined;
var _reactNative = _$$_REQUIRE(_dependencyMap[0]);
var _jsxRuntime = _$$_REQUIRE(_dependencyMap[1]);
var Hello = function Hello() {
return /*#__PURE__*/ (0, _jsxRuntime.jsx)(_reactNative.Text, {
style: styles.text,
children: "Hello"
});
};
var styles = _reactNative.StyleSheet.create({
text: {
color: 'black'
}
});
var _default = exports.default = Hello;
}, 481, [2, 223]);
__d(function (global, _$$_REQUIRE, _$$_IMPORT_DEFAULT, _$$_IMPORT_ALL, module, exports, _dependencyMap) {
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = undefined;
var _reactNative = _$$_REQUIRE(_dependencyMap[0]);
var _jsxRuntime = _$$_REQUIRE(_dependencyMap[1]);
var Hello = function Hello() {
return /*#__PURE__*/ (0, _jsxRuntime.jsx)(_reactNative.Text, {
style: styles.text,
children: "Hello"
});
};
var styles = _reactNative.StyleSheet.create({
text: {
color: 'black'
}
});
var _default = exports.default = Hello;
}, 482, [2, 223]);
view raw metro.js hosted with ❤ by GitHub

 

다른 것은 볼 필요없고, __d 함수의 두 번째 매개변수 번호와 세 번째 매개변수 번호만 보면 됩니다.

 

두 번째 매개변수는 각 파일(모듈)별로 할당된 번호들입니다. 

 

세 번째 매개변수는 해당 모듈이 참족하고 있는 모듈들(의존성)에 할당된 번호입니다.

 

metro는 각 파일(모듈)에 할당된 번호를 알고 있습니다.

 

__d는 다음과 같이 정의되어 있습니다.

var __METRO_GLOBAL_PREFIX__ = ''
global[`${__METRO_GLOBAL_PREFIX__}__d`] = define;
function clear() {
modules = new Map();
return modules;
}
function define(factory, moduleId, dependencyMap) {
if (modules.has(moduleId)) {
return;
}
var mod = {
dependencyMap: dependencyMap,
factory: factory,
hasError: false,
importedAll: EMPTY,
importedDefault: EMPTY,
isInitialized: false,
publicModule: {
exports: {}
}
};
modules.set(moduleId, mod);
}
view raw metro.js hosted with ❤ by GitHub

 

__d 함수를 사용하여 전역 객체의 modules 맵에 팩토리 함수를 저장해 놓고 사용/업데이트하는 방식입니다. (webpack과 비슷합니다.)

3-2. 반면 esbuild는 다음과 같이 번들링합니다.

// src/App.tsx
var import_react_native3 = __toESM(require_react_native());
// src/Hello.tsx
var import_react_native = __toESM(require_react_native());
var import_jsx_runtime = __toESM(require_jsx_runtime());
var Hello = () => {
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_native.Text, { style: styles.text, children: "Hello" });
};
var styles = import_react_native.StyleSheet.create({
text: {
color: "black"
}
});
var Hello_default = Hello;
// src/components/Hello.tsx
var import_react_native2 = __toESM(require_react_native());
var import_jsx_runtime2 = __toESM(require_jsx_runtime());
var Hello2 = () => {
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(import_react_native2.Text, { style: styles2.text, children: "Hello" });
};
var styles2 = import_react_native2.StyleSheet.create({
text: {
color: "black"
}
});
var Hello_default2 = Hello2;
// src/App.tsx
var import_jsx_runtime3 = __toESM(require_jsx_runtime());
function App() {
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_react_native3.View, { style: styles3.container, children: [
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react_native3.Text, { children: "App" }),
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Hello_default, {}),
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Hello_default2, {})
] });
}
var styles3 = import_react_native3.StyleSheet.create({
container: {
flex: 1
}
});
view raw esbuild.js hosted with ❤ by GitHub

 

왜 esbuild로 hmr을 구현하기 까다로운지 알 것 같았습니다.

 

ES 모듈은 모듈간 경계가 사라졌고, 동일한 심볼에는 파싱된 순서로 라벨이 붙습니다.

 

이 경우 변경된 파일(모듈)에서 선언된 변수가 전체 번들 파일에서 몇 번 라벨을 부여 받았었는지 알 수 있는 방법이 없습니다.

 

그리고 의존하고 있는 모듈들도 몇 번 라벨을 부여받았었는지 알 수 없습니다.

즉, 교체할 대상을 찾을 수 없고, 새로운 모듈과 연결할 모듈도 찾을 수 없다는 의미입니다.

4. vite는 어떻게 구현했나?

리액트 네이티브와 비슷한 코드로 테스트해 봤습니다.

import Hello1 from "./Hello";
import Hello2 from "./components/Hello";
function App() {
return (
<div style={styles.backgroundColor}>
<Hello1 />
<Hello2 />
</div>
);
}
const styles = { backgroundColor: "yellow" };
export default App;

 

개발자 툴의 네트워크 탭에서 확인해 보면 아래 코드로 변환되어 클라이언트로 전송됩니다.

// HMR 관련 코드 생략
import Hello1 from "/src/Hello.tsx";
import Hello2 from "/src/components/Hello.tsx";
function App() {
return /* @__PURE__ */ jsxDEV("div", { style: styles.backgroundColor, children: [
/* @__PURE__ */ jsxDEV(Hello1, {}, void 0, false, {
fileName: "/Users/joondong/Desktop/my-vite-react-app/src/App.jsx",
lineNumber: 7,
columnNumber: 7
}, this),
/* @__PURE__ */ jsxDEV(Hello2, {}, void 0, false, {
fileName: "/Users/joondong/Desktop/my-vite-react-app/src/App.jsx",
lineNumber: 8,
columnNumber: 7
}, this)
] }, void 0, true, {
fileName: "/Users/joondong/Desktop/my-vite-react-app/src/App.jsx",
lineNumber: 6,
columnNumber: 5
}, this);
}
const styles = { backgroundColor: "yellow" };
export default App;
// HMR 관련 코드 생략
view raw vite_build.jsx hosted with ❤ by GitHub

 

중요한건 (root 기준 경로로 바뀌긴 했지만) Hello 컴포넌트가 외부 모듈로 그대로 남아 있다는 것입니다. 

 

esbuild는 플러그인에서 모듈을 resolve할 때, 그 모듈을 external로 처리할지 결정할 수 있습니다.

 

vite는 esbuild의 bundle 속성을 true로 설정하지만, 각 모듈마다 external을 적극적으로 조작하여 대부분의 경우 external로 처리합니다.

 

그리고 브라우저는 ES 모듈 중에 Hello 컴포넌트가 없다면 개발 서버에 요청하면서 순차적으로 모듈을 받아옵니다.

vite hmr

 

ESM 환경에서 각 모듈은 고유한 스코프를 갖기 때문에, 어떤 파일의 내용이 변경되서 해당 파일을 entry로 빌드를 할 때, 다른 모듈에서 사용된 변수명에 대해 고민할 필요가 없습니다.

 

그리고 root 기준으로 변환된 경로를 사용하여 교체할 모듈을 찾을 수 있습니다.

 

공식 문서에도 나와있는 내용입니다.

https://ko.vite.dev/guide/features#npm-dependency-resolving-and-pre-building

 

이것은 브라우저가 ESM을 지원하기 때문에 가능한 것이고, 리액트 네이티브의 자바스크립트 런타임(JSC/Hermes)는 ESM을 지원하기 않기 때문에, 다른 방법을 찾아야 했습니다. (조금 응용하면 가능할지도 모르겠네요.)

 

5. 핵심 아이디어

5-1. ESM → CJS

esbuild는 ES 모듈들은 하나로 통합하지만, CJS 모듈은 __commonJS라는 함수로 묶어서 처리합니다.

 

예를 들어 3에서 사용된 예제를 @babel/plugin-transform-modules-commonjs 바벨 플러그인으로 변환하여 esbuild로 번들링하면 다음과 같은 결과가 생성됩니다.

var require_App = __commonJS({
"src/App.tsx"(exports) {
"use strict";
var _interopRequireDefault2 = require_interopRequireDefault();
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = App; // 요기 주목 !!
var _reactNative2 = require_react_native();
var _Hello = _interopRequireDefault2(require_Hello());
var _Hello2 = _interopRequireDefault2(require_Hello2());
var _jsxRuntime = require_jsx_runtime();
function App() {
return (0, _jsxRuntime.jsxs)(_reactNative2.View, { style: styles.container, children: [(0, _jsxRuntime.jsx)(_reactNative2.Text, { children: "App" }), (0, _jsxRuntime.jsx)(_Hello.default, {}), (0, _jsxRuntime.jsx)(_Hello2.default, {})] });
}
var styles = _reactNative2.StyleSheet.create({ container: { flex: 1 } });
}
});
view raw esbuild.js hosted with ❤ by GitHub

 

__commonJS는 require("App") 등을 흉내내기 위해 사용된 헬퍼 함수입니다.

var __commonJS = (cb, mod) => {
// 반환할 __require 함수 정의
function __require() {
// 이미 모듈이 초기화된 경우, 캐싱된 모듈 반환
if (mod) {
return mod.exports;
}
// 초기화되지 않은 경우, 새로운 모듈 객체 생성
mod = {
exports: {},
};
// cb에서 첫 번째 속성를 실행하여 모듈 초기화
const initFunction = cb[Object.keys(cb)[0]];
initFunction(mod.exports, mod);
// 초기화된 exports 반환
return mod.exports;
}
// __require 함수 반환
return __require;
};
view raw __commonJS.js hosted with ❤ by GitHub

 

예를 들어 위의 예에서 require__App() 함수가 처음 실행될 땐 "src/App.tsx"(exports) {...}가 실행되고, exports에 저장된 내용(exports.default = App 등)을 캐시해 둡니다.

 

그리고 그 다음 require__App() 함수가 실행될 땐, "src/App.tsx"(exports) {...} 함수의 내용은 실행되지 않고, 캐시된 exports 객체만 반환됩니다.

 

결론적으로 번들링된 파일에서 require__App()를 실행하는 것은 CJS에서 require("App")과 동일한 결과를 만들어 냅니다.

 

이로써 모듈 내부에서 사용된 변수 이름에 대해서는 신경쓸 필요가 없어졌습니다.

 

5-2. 모듈 팩토리 함수에 고유 ID 부여

하지만 앱에서 App이라는 파일이 여러 개 있다면 require__App 심볼의 고유성을 보장할 수가 없습니다.

 

처음에는 포기할까 했지만, 문득 esbuild 코드에서 심볼을 부여하는 코드를 찾아서 고유한 ID를 부여할 수는 없을까?라는 생각이 들었고, 관련 코드를 쉽게 찾을 수 있었습니다.

 

고유 ID는 파일(모듈)의 확장자를 포함한 절대경로를 사용하기로 했습니다.

 

파일 시스템에서 고유하면서 esbuild 외부에서 추론하기도 쉬웠기 때문입니다.

 

source.IdentifierName은 파일 이름이고, source.KeyPath.Text는 파일의 절대경로입니다.

https://github.com/evanw/esbuild/blob/v0.24.2/internal/js_parser/js_parser.go#L17994

 

이렇게 수정하면 3번 코드는 아래와 같이 변환됩니다.

// src/Hello.tsx
var require__users_joondong_desktop_react_native_esbuild_example_src_hello_tsx = __commonJS({
"/Users/joondong/Desktop/react-native-esbuild/example/src/Hello.tsx"(exports2) {
"use strict";
Object.defineProperty(exports2, "__esModule", { value: true });
exports2.default = void 0;
var _reactNative2 = require__users_joondong_desktop_react_native_esbuild_example_node_modules_react_native_index_js();
var _jsxRuntime = require__users_joondong_desktop_react_native_esbuild_example_node_modules_react_jsx_runtime_js();
var Hello = function Hello2() {
return (0, _jsxRuntime.jsx)(_reactNative2.Text, { style: styles.text, children: "Hello" });
};
var styles = _reactNative2.StyleSheet.create({ text: { color: "black" } });
var _default = exports2.default = Hello;
}
});
// src/components/Hello.tsx
var require__users_joondong_desktop_react_native_esbuild_example_src_components_hello_tsx = __commonJS({
"/Users/joondong/Desktop/react-native-esbuild/example/src/components/Hello.tsx"(exports2) {
"use strict";
Object.defineProperty(exports2, "__esModule", { value: true });
exports2.default = void 0;
var _reactNative2 = require__users_joondong_desktop_react_native_esbuild_example_node_modules_react_native_index_js();
var _jsxRuntime = require__users_joondong_desktop_react_native_esbuild_example_node_modules_react_jsx_runtime_js();
var Hello = function Hello2() {
return (0, _jsxRuntime.jsx)(_reactNative2.Text, { style: styles.text, children: "Hello" });
};
var styles = _reactNative2.StyleSheet.create({ text: { color: "black" } });
var _default = exports2.default = Hello;
}
});
// src/App.tsx
var require__users_joondong_desktop_react_native_esbuild_example_src_app_tsx = __commonJS({
"/Users/joondong/Desktop/react-native-esbuild/example/src/App.tsx"(exports2) {
"use strict";
var _interopRequireDefault3 = require__users_joondong_desktop_react_native_esbuild_example_node_modules__babel_runtime_helpers_interoprequiredefault_js();
Object.defineProperty(exports2, "__esModule", { value: true });
exports2.default = App;
var _reactNative2 = require__users_joondong_desktop_react_native_esbuild_example_node_modules_react_native_index_js();
var _Hello = _interopRequireDefault3(require__users_joondong_desktop_react_native_esbuild_example_src_hello_tsx());
var _Hello2 = _interopRequireDefault3(require__users_joondong_desktop_react_native_esbuild_example_src_components_hello_tsx());
var _jsxRuntime = require__users_joondong_desktop_react_native_esbuild_example_node_modules_react_jsx_runtime_js();
function App() {
return (0, _jsxRuntime.jsxs)(_reactNative2.View, { style: styles.container, children: [(0, _jsxRuntime.jsx)(_reactNative2.Text, { children: "App" }), (0, _jsxRuntime.jsx)(_Hello.default, {}), (0, _jsxRuntime.jsx)(_Hello2.default, {})] });
}
var styles = _reactNative2.StyleSheet.create({ container: { flex: 1 } });
}
});

 

흉내 모듈을 참조하는 부분이 특수 문자가 언더스코어로 변환되기는 했지만, 절대경로 기반의 심볼로 변환된 것을 확인할 수 있습니다. 

 

사실 디버깅과 이후 hmr로 전송되는 코드와의 일관성을 위해 __commonJS 함수에 입력되는 객체의 첫 번째 키도 절대경로로 수정헀는데, __commonJS 함수는 단 하나만 있는 첫 번째 키에만 접근하므로 큰 의미는 없습니다.

https://github.com/evanw/esbuild/blob/v0.24.2/internal/linker/linker.go#L4808

 

이로써 __commonJS에 의해 생성된 require 함수는 global에서 고유한 ID를 갖고, 이 함수만 재정의하면, 모듈을 교체할 수 있게 되었습니다.

 

결론적으론 3-1에서 metro는 global.modules에 모듈을 저장해 놓은 것이고, 제가 사용한 방식은 global에 모듈을 저장해 놓은 방식입니다.

 

global에 require__users_joondong_desktop_react_native_esbuild_example_src_app_tsx와 같은 변수명을 지정하지 않는 한 큰 문제는 없어 보였습니다.

 

이제 자바스크립트에서 교체해야할 대상을 명확하게 결정할 수 있게 되었습니다.

 

다만 esbuild는 기계어로 컴파일되어야 하기 때문에, 플랫폼별(윈도우,리눅스,맥+Intel/Arm 64비트만)로 수정된 코드로 미리 컴파일하여 로컬 모듈에 포함시켜 두었습니다.

https://github.com/JoonDong2/react-native-esbuild/tree/main/modules/esbuild-custom/prebuilt

 

이것때문에 repository 용량이 25Mb로 좀 큽니다.

 

6. 핵심 코드 설명

6-1. 번들링

번들링은 별거 없습니다.

 

바벨을 적용하고, 메인 모듈을 실행하기 전에 @react-native/js-polyfills을 적용하고, react-native/Libraries/Core/InitializeCore 모듈을 먼저 실행하면 됩니다.

https://github.com/facebook/react-native/blob/v0.76.6/packages/metro-config/src/index.flow.js#L61-L64

 

이 부분은 성능 이슈가 있는데, 마지막에 잠깐 다루겠습니다.

 

6-1-1. 설정값

라이브러리는 다양한 메인 필드를 제공합니다.

 

예를 들어  react-native-reanimated는 react-native, main, module 3가지 메인 필드를 제공합니다.

https://github.com/software-mansion/react-native-reanimated/blob/3.16.7/packages/react-native-reanimated/package.json#L35-L37

 

그리고 해당 라이브러리를 어떤 환경에서 어떻게 참조하는지에 따라서 어떤 모듈로 resolve해야 하는지에 대한 정보도 exports 필드를 통해 제공합니다.

 

예를 들어, babel-runtime은 node, require, default에 대한 resolve 정보를 제공합니다. 

https://github.com/babel/babel/blob/main/packages/babel-runtime/package.json#L19-L26

 

esbuild에서 메인 필드는 mainFields를 통해, exports는 conditions(또는 conditionsNames)를 통해 지정할 수 있습니다.

 

metro는 react-native, browser, main 순서로 필드를 탐색하고, exports 필드는 default, require, import, react-native 순서로 지정했습니다. 
https://github.com/facebook/react-native/blob/v0.76.6/packages/metro-config/src/index.flow.js#L55

https://github.com/facebook/metro/blob/v0.81.0/packages/metro-resolver/src/PackageExportsResolve.js#L307-L308

 

저도 metro와 같은 우선순위로 설정했습니다.

 

이 부분은 HMR 부분에서 문제가 됐는데, 6-2-5에서 다루겠습니다.

 

2025/01/22 수정

이 부분은 제가 잘못 분석했습니다.

metro를 포함한 대부분 번들러에 포함된 resolver는 conditionNames에 우선순위를 두지 않습니다.

package.json에 정의된 순서대로 사용여부를 결정합니다.
- esbuild: https://esbuild.github.io/api/#how-conditions-work
- webpack(enhanced-resolve): https://github.com/webpack/enhanced-resolve/issues/318#issuecomment-1055518577
- metro: https://metrobundler.dev/docs/configuration#unstable_conditionnames-experimental

ES6부터 자바스크립트 객체의 키에는 순서가 일부 도입되었으나 그 이전에는 순서를 보장할 수 없다고 생각하여 당연히 conditionNames에 우선순위가 있다고 생각하고 문서와 로직을 안보고 코드 정의부분만 살펴보았습니다.

잘못된 내용을 전파하여 죄송합니다.

그리고 metro는 기본적으로 conditionNames조차 사용하지 않습니다.
https://metrobundler.dev/docs/configuration/#unstable_conditionnames-experimental

unstable_enablePackageExports가 true일 때 conditionNames가 사용되는데, 기본값은 false이고, 이때는 경로와 확장자를 기반으로 파일을 찾습니다.

아래 conditionNames를 분석하는 부분을 건너뜁니다.
https://github.com/facebook/metro/blob/v0.81.0/packages/metro-resolver/src/resolve.js#L363-L409

이 부분때문에 @babel/runtime같은 모듈을 resolve할 때 esbuild와 다른 버전의 모듈을 선택할 수 있습니다.

이를 방지하기 위해 metro가 resolve를 위해 사용하는 metro-resolver를 그대로 가져와서  hmr을 포함한 모든 빌드를 위핸 resolve에 적용하도록 변경하였습니다.
https://github.com/JoonDong2/react-native-esbuild/commit/1a0d3be27c39a49dfe564015196220bfb61bef41https://github.com/JoonDong2/react-native-esbuild/blob/main/src/utils/metroResolve.ts

리액트 네이티브 기본 conditionNames를 그대로 사용하면 @babel/runtime을 resolve할 때 esm 버전을 선택(먼저 나오니까)하게 되는데, 이 부분을 @babel/plugin-transform-modules-commonjs 바벨 플러그인으로 CJS 모듈로 변환해서 __commonJS로 에뮬레이트하면 오류가 발생합니다.

esm 버전은 export { something as default }로 모듈을 내보내는데, @babel/plugin-transform-modules-commonjs는 이것을 exports.default = something으로 변환하기 때문에 발생하는 문제로 추정됩니다. (cjs 버전은 module.exports로 내보냅니다.)

babel-plugin-add-module-exports를 사용하면 해결할 수 있을 것 같긴 합니다.

metro-resolver에서도 conditionNames를 사용하는 경우 @babel/runtime에 대해서는 esm 버전을 사용하지 못하도록 특별한 처리를 하고 있는데, 아마 이것과 관련된 문제가 아닐까 싶습니다.
https://github.com/facebook/metro/blob/v0.81.0/packages/metro-resolver/src/resolve.js#L370-L378

이런 방법을 굳이 재현하고 싶지 않았기 때문에, metro-resolver의 기본 동작을 그대로 가져왔습니다.

 

format은 HMR에서 의존성 참조 구문을 변경해 주는 작업이 필요한데, require 형식이 더 단순하기 때문에 cjs로 설정하였습니다.

 

target의 기본값은 exnext지만 minify해서 Hermes에서 실행하면 ? Token 오류가 발생해서 es2015(es6)로 사용하였습니다.

export const getJsStyle = (): BuildOptions => {
return {
format: 'cjs',
target: 'es2015',
};
};
export const getMainFields = () => {
return ['react-native', 'browser', 'main'];
};
export const getConditionNames = () => {
return ['default', 'require', 'import', 'react-native'];
};
view raw config.js hosted with ❤ by GitHub

https://github.com/JoonDong2/react-native-esbuild/blob/main/src/constants/config.ts

 

6-1-2. Chaining Loaders Plugin

esbuild는 webpack에 비해 플러그인 구조가 매우 심플했습니다.

 

그런데 다수의 플러그인이 적용된 경우, 플러그인들이 순차적으로 모듈을 다루지만, undefined를 반환하면 다음 플러그인에 처리를 위임하고, 어떤 플러그인에서 모듈을 처리하면 끝입니다.

 

즉, 두 개 이상의 플러그인이 하나의 모듈에 작업을 할 수 없습니다.

 

esbuild는 두 개의 엔트리 포인트를 하나의 번들로 합치는 기능을 제공하지 않기 때문에, 6-1에서 언급한 기존 엔트리 포인트에 react-native/Libraries/Core/InitializeCore 모듈을 실행하는 코드를 추가해야 하는데, 바벨 플러그인이 먼저 적용된 상태에선 추가할 기회를 얻을 수 없없습니다.

 

반대로 코드를 먼저 추가하면, 바벨 플러그인을 적용할 수 없없습니다.

 

HMR 관련 코드도 번들링에 사용된 바벨이 적용된 다음 추가되어야 합니다.

 

물론 특정 모듈에 두 가지 동작을 처리하는 플러그인을 적용할 수 있지만, 플러그인이 단일 책임을 만족하지 않고, 코드 중복이 발생하여 그렇게 하지는 않았습니다.

 

그래서 로더(자체 정의) 배열을 받아서, 모든 로더에게 처리할 기회를 주는 플러그인을 만들어 사용했습니다.

interface ChainingLoader {
// 필터링되면 다음 플러그인에 위임
include?: Filter[];
exclude?: Filter[];
// undefined 반환하면 다음 플러그인에 위임
load: (
prevResult: OnLoadResult,
originArgs: OnLoadArgs
) => OnLoadResult | undefined | Promise<OnLoadResult | undefined>;
}
const makePluginByChangingLoaders = (
config: Config,
loaders: (ChainingLoader | undefined | null)[]
): Plugin => {
const { name, filter, preprocess = defaultPreProcess } = config;
// 각 로더의 include, exclude 필터를 정규식으로 변환
const loadersWithRegExp = loaders
.filter((l) => !!l)
.map<WithMergedRegExp<ChainingLoader>>((loader) => {
return {
...loader,
includeRegExp: mergeFilters(loader.include),
excludeRegExp: mergeFilters(loader.exclude),
};
});
return {
name,
setup(build) {
// 필터는 플러그인 전체 필터와 로더 개별 필터로 구분
// 플러그인 필터는 필터링되면 모든 로더가 처리하지 않지만,
build.onLoad({ filter: filter || /\.*/ }, async (args) => {
let result: OnLoadResult = await preprocess(args);
for (const loader of loadersWithRegExp) {
const { includeRegExp, excludeRegExp } = loader;
// 로더 필터는 필터링되면 다음 로더가 처리
if (
(includeRegExp && !includeRegExp.test(args.path)) ||
(!includeRegExp && excludeRegExp && excludeRegExp.test(args.path))
) {
continue;
}
const newResult = await loader.load(result, args);
// undefined를 반환해도 다음 로더가 처리
if (newResult) {
result = newResult;
}
}
return result;
});
},
};
};

https://github.com/JoonDong2/react-native-esbuild/blob/4fad7c515c1fa1711e10815a5cb97b60234d50cc/src/plugins/esbuild/makePluginByChangingLoaders.ts#L41-L72

 

6-1-3. 바벨

리액트 네이티브는 대부분 flow로 작성되었기 때문에, esbuild로 바로 빌드할 수 없습니다.

 

또한 Hermes 엔진에 맞는 변환과 ES 모듈을 CJS 모듈로 변환하는 과정도 필요합니다.

https://github.com/facebook/react-native/blob/v0.76.6/packages/react-native-babel-preset/src/configs/main.js#L112-L116

 

이것을 react-native-babel-preset이 제공하고, react-native cli로 프로젝트를 생성하면 root/babel.config.js에 기본적으로 적용되어 있습니다.

 

이 설정을 그대로 사용합니다.

export const getUserBabelConfig = (root: string) => {
return getConfig(`${root}/babel.config.js`);
};
view raw config.js hosted with ❤ by GitHub

https://github.com/JoonDong2/react-native-esbuild/blob/4fad7c515c1fa1711e10815a5cb97b60234d50cc/src/utils/BuildContext.ts#L85

 

그리고 esbuild 플러그인을 통해 바로 적용하는게 아니라, 위에서 설명한 Chaining Loaders Plugin의 Chaining Loader를 통해 적용합니다.

import { transformAsync } from '@babel/core';
import path from 'path';
import fs from 'fs';
export const babelLoader = ({
babelConfig,
...options
}: Props) => {
return {
...options,
load: async (prevResult, originArgs) => {
const { contents } = prevResult;
if (!contents) return;
const loader = path.extname(originArgs.path).slice(1)
// 바벨 적용
const transformed = await transformAsync(contents, {
...babelConfig,
filename: originArgs.path,
sourceMaps: false,
});
if (transformed === null) {
throw new Error('babel failed !!');
}
const result = {
contents: transformed.code ?? '',
loader,
};
return result;
},
};
};
view raw babel.js hosted with ❤ by GitHub

 

6-1-4. InitializeCore

리액트 네이티브에서 사용하는 모듈을 global에 정의하는 모듈로 메인 모듈이 실행되기 전에 실행해야 합니다.

 

그렇지 않으면 앱을 실행할 때 global.performance.now에 접근하면서 크래시가 발생합니다.

 

역시 Chaining Loader를 통해 적용했습니다.

 

그냥 applyIds의 각 모듈의 맨 위에 modules의 모든 모듈을 import하는 코드를 추가하는 로더입니다.

export const importVirtualModulesLoader = ({
modules,
applyIds,
}: Props) => {
return {
include: applyIds,
load(prevResult, originArgs) {
if (!prevResult.contents) {
throw new Error('wrong entry !!');
}
return {
contents: `${modules.map((mod) => `import "${mod}";`).join('\n')}\n${contents}`,
loader: path.extname(originArgs.path).slice(1),
};
},
};
};

 

applyIds에는 엔트리 파일만 입력했습니다.

importVirtualModulesLoader({
modules: ['react-native/Libraries/Core/InitializeCore'],
applyIds: [entryFile],
})
view raw bundle.js hosted with ❤ by GitHub

https://github.com/JoonDong2/react-native-esbuild/blob/4fad7c515c1fa1711e10815a5cb97b60234d50cc/src/commands/bundle.ts#L42-L45

 

6-1-5. @react-native/js-polyfills

console이나 ErrorUtils 등 리액트 네이티브 환경에서 특수하게 처리되는 코드를 global에 추가해주어야 합니다.

 

역시 메인 모듈 실행전에 작동해야 하지만, 메인 모듈과 의존성은 없기 때문에, 따로 빌드하여 herader에 추가하였습니다.

const polyfills = await getJsPolyfills({
sourceRoot: root,
define: getDefine(false),
minify: true,
});
const buildContext = await BundleService.create({
// ...
header: polyfills,
});
view raw bundle.js hosted with ❤ by GitHub

https://github.com/JoonDong2/react-native-esbuild/blob/4fad7c515c1fa1711e10815a5cb97b60234d50cc/src/commands/bundle.ts#L25-L40

 

특이한게 esbuild는 번들링하지 않고 변환만 할 때, format을 cjs나 esm으로 하면 내부에서 사용된 변수를 전역 변수로 끌어 올리는데, JSC에서는 이렇게 끌어 올려진 전역변수는 자동으로 configurable 속성이 false가 되어 polyfills에서 수정이 안되는 문제가 있었습니다.

 

그래서 polyfills는 메인 모듈과 달리 즉시실행함수(iife)로 설정하였습니다.

https://github.com/JoonDong2/react-native-esbuild/blob/4fad7c515c1fa1711e10815a5cb97b60234d50cc/src/constants/polyfills.ts#L16

 

iife로 설정하면 전역 변수로 설정되는 것을 막을 수 있습니다.

https://esbuild.github.io/api/#format-iife

 

6-1-6. BuildContext

위에서 설명한 내용은 BuildContext 내부에 미리 정의되어 있거나, 외부에서 BuildContext에 입력됩니다.

 

리액트 네이티브 앱을 실행할 때 커맨드 라인에 입력하는 npm run(또는 yarn) android/ios/start를 실행하거나 앱을 빌드할 때, react-native.config.js의 commands 필드에 정의된 start, bundle 함수를 실행합니다.

 

bundle 함수에선 자바스크립트 파일들을 정해진 위치에 번들링하면 되고, start 함수에선 입력받은 호스트와 포트로 번들링 결과물과 HMR 코드 조각을 제공하는 서버를 실행하면 됩니다.

 

bundle 함수나 개발 서버에서 빌드용/개발용/HMR용으로 3가지 번들링을 수행하는데, 이것들을 모두 BuildContext에서 처리합니다.

BuildContext

https://github.com/JoonDong2/react-native-esbuild/blob/main/src/utils/BuildContext.ts

 

6-2. HMR

HMR은 react-refresh 기반으로 처리합니다.

 

만약 리액트 컴포넌트가 없는 모듈이 변경되었다면, 앱 전체를 리로드합니다.

6-2-1. react-refresh/runtime

register와 perfromReactRefresh, createSignatureFunctionForTransform 세 개의 핵심 메서드를 포함합니다.

 

register 메서드는 type과 id를 입력으로 받는데, 주로 type에는 컴포넌트(함수), id는 "절대 경로 + 컴포넌트 이름"을 입력합니다.

 

처음 실행될 때는 allFamiliesById 맵에 저장만해 두고, 두 번째 실행(모듈 교체가 필요한 경우)부터 pendingUpdates에 넣어 리액트 트리에서 해당하는 부분의 업데이트 준비를 합니다.

export function register(type, id): void {
// ...
let family = allFamiliesByID.get(id);
if (family === undefined) {
family = {current: type};
allFamiliesByID.set(id, family);
} else {
pendingUpdates.push([family, type]);
}
// ...
}

https://github.com/facebook/react/blob/v19.0.0/packages/react-refresh/src/ReactFreshRuntime.js#L319-L325

 

그리고 performReactRefresh 함수를 실행하면 pendingUpdates에 있는 업데이트를 꺼내서 마운트 또는 리랜더링시킵니다.

export function performReactRefresh(){
try {
const staleFamilies = new Set();
const updatedFamilies = new Set();
const updates = pendingUpdates; // from register
pendingUpdates = [];
// 업데이트 결정
updates.forEach(([family, nextType]) => {
// Now that we got a real edit, we can create associations
// that will be read by the React reconciler.
const prevType = family.current;
updatedFamiliesByType.set(prevType, family);
updatedFamiliesByType.set(nextType, family);
family.current = nextType;
// Determine whether this should be a re-render or a re-mount.
if (canPreserveStateBetween(prevType, nextType)) {
updatedFamilies.add(family);
} else {
staleFamilies.add(family);
}
});
// ...
// 업데이트들
const update = {
updatedFamilies, // Families that will re-render preserving state
staleFamilies, // Families that will be remounted
};
mountedRootsSnapshot.forEach(root => {
const helpers = helpersByRootSnapshot.get(root);
// ...
try {
helpers.scheduleRefresh(root, update); // 업데이트 수행
} catch (err) {
// ...
}
});
return update;
} finally {
isPerformingRefresh = false;
}
}

https://github.com/facebook/react/blob/v19.0.0/packages/react-refresh/src/ReactFreshRuntime.js#L204-L226

https://github.com/facebook/react/blob/v19.0.0/packages/react-refresh/src/ReactFreshRuntime.js#L283

 

react-refresh는 개발 문서가 없는데, 개발자가 직접 작성한 대략적인 가이드react-refresh-webpack-plugin을 참고했습니다. (참고로 enqueueUpdate는 debounce가 적용된 performReactRefresh 함수입니다.)

 

createSignatureFunctionForTransform는 서명 함수를 생성하는데, 각 컴포넌트마다 하나씩 생성해서, 각 컴포넌트 내부에서 초기화한 다음, 각 컴포넌트 밖에서 해당 컴포넌트 안에서 사용된 훅의 ID(useState, useEffect 등 훅마다 고유 ID가 있음)를 입력해 주면, 리랜더링시 상태를 유지하기 위해 사용될 수 있습니다.

 

이 부분은 내부 로직을 확인해 보지 않아서 어떻게 작동하는지는 잘 모르겠습니다.

 

6-2-2. react-refresh/babel

그런데 register로 컴포넌트와 id를 직접 등록하려면 좀 까다롭습니다.

 

다음과 같은 모듈안에 3개의 컴포넌트 함수와 일반 함수가 있다면, 함수가 컴포넌트인지 판단해야 합니다.

 

그리고 컴포넌트 안에서 훅이 사용되었다면, 각 훅마다 서명 함수 처리를 해 주어야 합니다.

import { useState } from 'react';
import { Text, View } from 'react-native';
const sayHello = (name: string) => {
console.log('hello', name);
};
const Hello = () => {
return <Text>Hello</Text>;
};
const Hi = () => {
const [name, setName] = useState('joondong');
return <Text>{`Hi ${name}`}</Text>;
};
export default function App() {
const [name, setName] = useState('joondong');
sayHello(name);
return (
<View style={{ flex: 1 }}>
<Hello />
<Hi />
</View>
);
}
view raw App.tsx hosted with ❤ by GitHub

 

이것을 react-refresh/babel에서 AST(Abstract Syntax Tree)를 분석하여 자동으로 처리해 줍니다.

 

위의 코드에 react-refresh/babel을 적용하면 다음과 같이 변환됩니다.

var require__users_joondong_desktop_react_native_esbuild_example_src_app_tsx = __commonJS({
"/Users/joondong/Desktop/react-native-esbuild/example/src/App.tsx"(exports2) {
"use strict";
var _interopRequireDefault3 = require__users_joondong_desktop_react_native_esbuild_example_node_modules__babel_runtime_helpers_interoprequiredefault_js();
Object.defineProperty(exports2, "__esModule", { value: true });
exports2.default = App;
var _slicedToArray22 = _interopRequireDefault3(require__users_joondong_desktop_react_native_esbuild_example_node_modules__babel_runtime_helpers_slicedtoarray_js());
var _react = require__users_joondong_desktop_react_native_esbuild_example_node_modules_react_index_js();
var _reactNative2 = require__users_joondong_desktop_react_native_esbuild_example_node_modules_react_native_index_js();
var _jsxRuntime = require__users_joondong_desktop_react_native_esbuild_example_node_modules_react_jsx_runtime_js();
var _s = $RefreshSig$();
var _s2 = $RefreshSig$();
var sayHello = function sayHello2(name) {
console.log("hello", name);
};
var Hello = function Hello2() {
return (0, _jsxRuntime.jsx)(_reactNative2.Text, { children: "Hello" });
};
_c = Hello;
var Hi = function Hi2() {
_s();
var _useState = (0, _react.useState)("joondong"), _useState2 = (0, _slicedToArray22.default)(_useState, 2), name = _useState2[0], setName = _useState2[1];
return (0, _jsxRuntime.jsx)(_reactNative2.Text, { children: `Hi ${name}` });
};
_s(Hi, "p7/tu5WykzKJDKF9ESNWXkaTJCM=");
_c2 = Hi;
function App() {
_s2();
var _useState3 = (0, _react.useState)("joondong"), _useState4 = (0, _slicedToArray22.default)(_useState3, 2), name = _useState4[0], setName = _useState4[1];
sayHello(name);
return (0, _jsxRuntime.jsxs)(_reactNative2.View, { style: { flex: 1 }, children: [(0, _jsxRuntime.jsx)(Hello, {}), (0, _jsxRuntime.jsx)(Hi, {})] });
}
_s2(App, "p7/tu5WykzKJDKF9ESNWXkaTJCM=");
_c3 = App;
var _c;
var _c2;
var _c3;
$RefreshReg$(_c, "Hello");
$RefreshReg$(_c2, "Hi");
$RefreshReg$(_c3, "App");
}
});

컴포넌트가 아닌 sayHello 함수는 무시되었고, Hello, Hi, App 컴포넌트가 $RefreshReg$에 의해 처리되는 코드가 추가되었습니다.

 

그리고 훅이 있는 Hi, App 컴포넌트만 서명 로직($RefreshSig$)이 추가되었습니다.

 

하지만 $RefreshReg$와 $RefreshSig$는 개발자가 직접 정의해 주어야 합니다.

 

당연히 react-refresh/runtime의 register와 createSignatureFunctionForTransform가 사용되어야 하지만, 개발자가 자신의 환경에 맞게 커스텀할 수 있도록 해 주었습니다.

 

저는 가이드 내용 그대로 정의했습니다.

 

이것을 Chaining Loader를 통해 바벨이 적용된 코드 앞뒤로 추가했습니다.

import dedent from 'dedent';
import type { ChainingLoader } from './makePluginByChangingLoaders';
import path from 'path';
import type { Loader } from 'esbuild';
export default (
filter: (path: string, contents?: string) => boolean
): ChainingLoader => {
return {
load(prevResult, originArgs) {
const loader = path.extname(originArgs.path).slice(1) as Loader;
const contents = prevResult.contents
if (!filter(originArgs.path, contents)) {
return;
}
return {
contents:
dedent`
var RefreshRuntime = require("react-refresh/runtime");
var prevRefreshReg = globalThis.$RefreshReg$;
globalThis.$RefreshReg$ = (type, id) => {
const fullId = "${originArgs.path}" + " " + id;
RefreshRuntime.register(type, fullId);
};
globalThis.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform;
` +
contents +
dedent`
globalThis.$RefreshReg$ = prevRefreshReg;
RefreshRuntime.performReactRefresh();
`,
loader,
};
},
};
};
view raw addRefresh.tsx hosted with ❤ by GitHub

https://github.com/JoonDong2/react-native-esbuild/blob/main/src/plugins/esbuild/addRefresh.ts

 

이것을 개발 서버에서 번들링 파일을 만들 때, hmr 청크를 만들 때, 리액트 컴포넌트가 있는 모듈에만 적용합니다.

// get /index.bundle
buildContext = await BuildContext.create({
// ...
scriptLoaders: [
addRefresh(
(_, contents) => !!contents && hasReactComponent(contents)
),
// ...
],
});
const hasReactComponent = (contents: string) => {
return /(?:\$RefreshReg\$|\$RefreshSig\$)/.test(contents);
};
view raw start.ts hosted with ❤ by GitHub

https://github.com/JoonDong2/react-native-esbuild/blob/ac72e0a7717a84a8978047b6180d0635563d74fe/src/commands/start.ts#L83-L85

 

6-2-3. HMRClient 교체

제가 만든 개발서버는 기존의 HMRClient와 호환되지 않습니다.

 

따라서 기존의 HMRClient를 resolve할 때 제가 만든 HMRClient로 교체해 줍니다.

const replaceHMRClient = (): Plugin => {
return {
name: 'replace-hmr-client',
setup(build) {
build.onResolve({ filter: /HMRClient$/ }, () => {
return {
path: require.resolve('../../hmr/HMRClient'),
};
});
},
};
};

https://github.com/JoonDong2/react-native-esbuild/blob/main/src/plugins/esbuild/replaceHMRClient.ts

 

그리고 번들링할 때 적용해 줍니다.

// get /index.bundle
buildContext = await BuildContext.create({
// ...
plugins: [replaceHMRClient()],
// ...
}
view raw start.ts hosted with ❤ by GitHub

https://github.com/JoonDong2/react-native-esbuild/blob/ac72e0a7717a84a8978047b6180d0635563d74fe/src/commands/start.ts#L81

 

HMR에만 초점을 맞췄기 때문에, 정말 간단하게 구성헀습니다.

 

웹소켓을 사용하여 개발 서버로 부터 HMR 청크를 받으면 그대로 런타임에 적용하고 끝입니다.

const __ESBUILD_HMR_PATH__ = 'hot';
const __ESBUILD_HMR_CLIENT_ID__ = [...Array(10)]
.map(() => Math.random().toString(36)[2])
.join('');
const connect = (uri: string) => {
const ws = new global.WebSocket(uri);
ws.onclose = function onclose() {
setTimeout(() => {
ws.close();
connect(uri);
}, 1000);
};
ws.onmessage = (event) => {
const { file, contents, type } = JSON.parse(event.data);
switch (type) {
case 'update':
require('react-native-esbuild').evaluateJavascript(contents);
}
};
return ws;
};
module.exports = {
setup(
platform: string,
bundleEntry: string,
host: string,
port: number | string,
isEnabled: boolean,
scheme = 'ws'
) {
if (!isEnabled) return;
const uri = `${scheme}://${host}:${port}/${__ESBUILD_HMR_PATH__}?platform=${platform}&client_id=${__ESBUILD_HMR_CLIENT_ID__}`;
connect(uri);
},
};
view raw HMRClient.ts hosted with ❤ by GitHub

https://github.com/JoonDong2/react-native-esbuild/blob/main/src/hmr/HMRClient.ts

 

6-2-4. evaluateJavascriptAsync

런타임에 직접 접근해서 자바스크립트 코드를 평가하는 네이티브 함수입니다.

 

repack에 있는 코드를 그대로 가져왔습니다.

https://github.com/callstack/repack/blob/main/packages/repack/android/src/main/cpp/NativeScriptLoader.cpp#L13

https://github.com/callstack/repack/blob/main/packages/repack/ios/ScriptManager.mm#L323

 

처음엔 리액트 네이티브에선 eval을 못쓰는줄 알고 추가했습니다.

 

eval을 사용할 수 있다는 것을 알고 나중에 지우려고 했지만, 다행히 쓸모가 있었습니다.

 

esbuild는 tsconfig.json의 strict 또는 alwaysStrict 속성이 true인 경우 target이 cjs일 때 strict 모드로 번들링합니다.

https://esbuild.github.io/content-types/#tsconfig-json

 

기본 설정을 변경하거나 강제하고 싶지 않았습니다.

 

그런데 eval은 strict 모드인 경우엔 독립적인 스코프를 구성한다는 문제가 있었습니다.

function a() {
eval("var aa = 1");
console.log(aa);
}
function b() {
"use strict";
eval("var bb = 1");
console.log(bb);
}
a(); // 1
b(); // ReferenceError: bb is not defined
view raw test.js hosted with ❤ by GitHub

아니면 eval을 사용하는 대신에 HMR 청크에 있는 모든 전역 변수를 분석해서 global에 직접 추가할 수도 있었지만, 이왕 추가해 놓은 것을 그대로 사용하기로 했습니다. 

 

6-2-5. 변환된 파일(모듈)의 external 판단

Gaze를 사용하여 파일을 감시합니다.

 

파일 변경이 감지되면 해당 파일을 엔트리 포인트로 빌드를 수행합니다.

 

그리고 esbuild는 번들링 후에 metafile이라는 정보를 제공해 줍니다.

 

최종 결과물에 어떤 파일이 입력으로 사용되었는지에 대한 정보가 포함되어 있습니다.

 

모듈(파일)을 resolve할 때 metafile에 해당 모듈이 이미 포함되어 있다면 external 속성을 true로 하여 반환해 줍니다. 

watcher.on('all', (event: Event, file: string) => {
const inputs = extractInputs(buildContextOf[platform]?.last?.metafile);
if (!inputs) return;
const buildContext = await BuildContext.create({
// ...
entryFile: file, // !!
// ...
plugins: [
{
name: 'decide-external',
setup(build) {
build.onResolve({ filter: /\.*/ }, (args) => {
const absPath = getAbsolutePath({
path: args.path,
basefile: args.importer,
extensions: getResolveExtensions(platform),
});
const relativePath = getRelativePath(absPath, root);
// !args.importer is changed file
const external = !!args.importer && !!inputs[relativePath];
return {
external, // !!
path: absPath,
};
});
},
},
],
});
const result = await buildContext.build();
const code = result.outputFiles?.[0]?.text;
// ...
});
view raw configureHMR.ts hosted with ❤ by GitHub

https://github.com/JoonDong2/react-native-esbuild/blob/ac72e0a7717a84a8978047b6180d0635563d74fe/src/hmr/configureHMR.ts#L58-L85

 

그럼 esbuild는 해당 모듈에 대한 참조를 require("절대경로")로 변환합니다.

 

여기서 중요한 것은 6-1-1에서 mainFields와 conditions에 설정한 우선순위에 따라 esbuild가 선택했던(아니면 선택했을) 파일을 그대로 찾아와야 한다는 것입니다.

 

2025/01/25 수정

6-1-1에서 추가 설명한 것과 같이 모든 빌드시 metro-resolver를 사용하여 resolve하는 방식으로 변경하여기 때문에 enhanced-resolve는 제거하였습니다.

 

이상적으로는 번들러가 resolver까지 제공해 주면 좋겠지만, esbuild는 제공해 주지 않습니다.

 

대안으로 mainFields와 conditionNames(conditions)를 지원하는 enhanced-resolve 라이브러리를 사용했습니다.

 

이 라이브러리는 webpack에서도 resolve를 위해 사용하는 라이브러리이기도 합니다.

 

그런데 이상하게 babel을 참조할 때 esbuild와 다른 condition을 선택하는 문제가 발생했습니다.

 

코드를 확인해 보니, enhanced-resolve는 conditionNames 배열을 받을 수는 있지만, 배열 내 우선순위를 고려하지 않는다는 것을 알게 되었습니다.

 

어쩔 수 없이 해당 부분을 고쳐서 로컬 모듈로 포함시켰습니다.

원본: https://github.com/webpack/enhanced-resolve/blob/v5.18.0/lib/util/entrypoints.js#L477-L480

수정본: https://github.com/JoonDong2/react-native-esbuild/blob/ac72e0a7717a84a8978047b6180d0635563d74fe/modules/enhanced-resolve/dist/util/entrypoints.js#L479-L494

 

이렇게 번들링된 결과물을 바로 사용 클라이언트로 전송할 수는 없습니다.

 

external 처리되어 require("절대경로")로 남아 있는 코드를 require__절대경로()로 변환해 주어야 합니다.

 

이 부분은 바벨로 AST를 분석하여 안전하게 처리했습니다.

 

그리고 metafile에 새로 resolve된 모듈들을 추가해 줍니다.

watcher.on('all', (event: Event, file: string) => {
// ...
const buildContext = await BuildContext.create({
// ...
});
const result = await buildContext.build();
const code = result.outputFiles?.[0]?.text;
updateInputs(inputs, extractInputs(result.metafile)!); // !!
const transformedCode = await transformAsync(code, {
plugins: [
require('../plugins/babel/replaceRequireToFunction')(platform), // !!
],
filename: file,
compact: false,
sourceMaps: false,
});
});
view raw configureHMR.ts hosted with ❤ by GitHub

https://github.com/JoonDong2/react-native-esbuild/blob/ac72e0a7717a84a8978047b6180d0635563d74fe/src/hmr/configureHMR.ts#L129-L138

 

import * as babelTypes from '@babel/types';
import { type PluginObj } from '@babel/core';
import { getAbsolutePath, removeLeadingSlash } from '../../utils/path';
import { getResolveExtensions } from '../../constants/config';
module.exports = function (platform: string) {
const extensions = getResolveExtensions(platform);
return function ({ types: t }: { types: typeof babelTypes }): PluginObj {
return {
visitor: {
CallExpression(path, state) {
const callee = path.get('callee');
const args = path.get('arguments');
// require를 호출하고, 첫 번째 파라미터가 문자열인 경우
if (
callee.isIdentifier({ name: 'require' }) &&
args.length === 1 &&
args[0]?.isStringLiteral()
) {
const requirePath = args[0].node.value;
const transformedPath = removeLeadingSlash(
getAbsolutePath({
path: requirePath,
basefile: state.filename,
extensions,
})
);
const newCallee = t.identifier(
`require__${transformedPath.replace(/[/.@-]/g, '_').toLocaleLowerCase()}`
);
// require(callee)를 newCallee로 변경하고 파라미터 제거
path.replaceWith(t.callExpression(newCallee, []));
}
},
},
};
};
};

https://github.com/JoonDong2/react-native-esbuild/blob/main/src/plugins/babel/replaceRequireToFunction.ts

 

6-2-6. 엔트리 포인트 래핑

5-1에서 CJS 모듈로 변경하면 __commonJS로 래핑된다고 했었는데, 사실 엔트리 포인트는 예외입니다.

 

엔트리 포인트만 __commonJS로 래핑하고 실행시켜 주는 코드를 추가해 줍니다.

watcher.on('all', (event: Event, file: string) => {
// ...
const buildContext = await BuildContext.create({
// ...
scriptLoaders: [
// ...
{
include: [file], // !!
load(prevResult) {
const snakedFileName = toSnakeCase(
removeLeadingSlash(file)
).toLocaleLowerCase();
return {
loader: prevResult.loader,
contents: dedent`
var prev_require__${snakedFileName} = require__${snakedFileName};
var require__${snakedFileName} = __commonJS({
"${file}"(exports, module) {
${prevResult.contents}
}
});
require__${snakedFileName}();
`,
};
},
},
],
});
const result = await buildContext.build();
const code = result.outputFiles?.[0]?.text;
// ...
});
view raw configureHMR.ts hosted with ❤ by GitHub

https://github.com/JoonDong2/react-native-esbuild/blob/ac72e0a7717a84a8978047b6180d0635563d74fe/src/hmr/configureHMR.ts#L100-L119

 

 

이렇게 변경된 코드를 그대로 클라이언트로 전송해서 실행시켜주면 됩니다.

 

7. 예제 설명

Counter, OpacityAnimation, Navigation 3개의 컴포넌트가 있습니다.

  • Counter: 상태만 있는 간단한 컴포넌트
  • OpacityAnimation: react-native-reanimated가 적용된 컴포넌트
  • Navigation: react-navigation이 적용된 컴포넌트

OpacityAnimation과 Navigation은 사용되 않아서 트리 섀이킹되기 때문에, 번들 파일에는 포함되지 않습니다.

import { View } from 'react-native';
import Counter from './Counter';
import OpacityAnimation from './OpacityAnimation';
import Navigation from './Navigation';
export default function App() {
return (
<View style={{ flex: 1 }}>
<Counter />
{/* <OpacityAnimation /> */}
{/* <Navigation /> */}
</View>
);
}
view raw App.tsx hosted with ❤ by GitHub

https://github.com/JoonDong2/react-native-esbuild/blob/main/example/src/App.tsx

 

즉, 해당 컴포넌트들에서 사용하고 있는 react-native-reanimated와 react-navigation까지 포함되어 있지 않은 상태입니다.

 

이 상태에서 OpacityAnimation 주석을 해제하면, OpacityAnimation와 react-native-renimated가 번들링되어 클라이언트로 전송됩니다.

 

react-native-renimated까지 번들링되기 때문에 시간이 좀 걸립니다.

 

그 다음부터 수정할 땐 빠르게 되는 것을 확인할 수 있습니다.

 

Navigation도 마찬가지입니다.

 

 

 

8. 고민해볼 것들

8-1. 빌드 속도

빌드 속도가 다른 프로젝트들에 비해 좀 느립니다.

 

모들 모듈을 자바스크립트의 fs로 읽어서 react-native-babel-preset을 적용했기 때문입니다.

 

react-native-babel-preset에 @babel/plugin-transform-modules-commonjs이 포함되어 있어서 그냥 썼지만, 사실 모든 모듈에 react-native-babel-preset을 적용할 필요는 없습니다.

 

react-native, @react-native, abort-controller, whatwg-fetch 정도만 적용해 주어도 됩니다.

 

그래도 5-1에서 언급했듯이, 모든 모듈을 CJS 모듈로 확정하기 위해 fs로 읽어서 최소한 @babel/plugin-transform-modules-commonjs는 적용해 주어야 하는데, 사실 바벨보다 fs로 파일을 읽어들이는 부분이 병목일 것 같아서 성능이 얼마나 향상될 지는 모르겠습니다.

 

bundle, start, hmr용 빌드마다 적용되는 플러그인을 좀 더 정교하게 조작해서, 약간의 성능 향상은 기대해 볼 수 있을 것 같습니다.

 

8-2. 편의 기능

프로덕션용 번들링, GET /index.bundle API와 서버→클라이언트로 HMR 조각을 전달하는 것 외에는 기능이 없습니다.

 

console.log도 개발 서버로 전송이 안되고, 리로드하려면 콘솔창에서는 안되고 에뮬레이터에서 r을 연타해야 합니다.

 

8-3. 이미지 처리

HMR에만 초점을 맞췄기 때문에 이미지는 fakeAssetsLoader로 제거했습니다.

 

8-4. example/esbuild.config.js

예제 폴더의 esbuild.config.js에 추가된 workspace 플러그인은 무엇을 위한 것일까요?

 

예제 프로젝트는 라이브러리 프로젝트의 workspace의 일부지만, 호이스팅되지 않게 설정되어 node_modules가 프로젝트와 독립적으로 생성됩니다.

 

리액트 네이티브 라이브러리 탬플릿을 만들어주는 create-react-native-library를 사용했을 때 기본설정인데 그대로 사용했습니다.

 

이 경우 라이브러리가 사용하는 react-native예제 프로젝트에서 react-native가 달라져서 react-native가 두 번 빌드되어 번들에 포함되는 문제가 있었습니다.

 

이를 방지하기 위해 라이브러리와 예제 모두에 포함된 라이브러리는 특정 경로에서 resolve하도록 고정시켜 놓은 플러그인입니다.

 

모노 레포를 구성하여 라이브러리와 예제 프로젝트 모두 workspace로 구성하고, 호이스팅을 제한하지 않으면, 굳이 이렇게 하지 않아도 되는데, 하나뿐인 라이브러리를 모노레포까지 구성할 필요가 있을지 의문이었고, 라이브러리가 프로젝트 상위 경로에 있는 매우 예외적인 경우이기 때문에, 예제 프로젝트만을 위한 플러그인을 따로 만들어 사용하였습니다.

 

8-5. esbuild 플러그인 제한

6-1-2에서 언급하였지만 esbuild 플러그인은 어떤 플러그인에서 먼저 처리되면, 다른 플러그인은 모듈을 처리할 기회를 얻을 수 없습니다.

 

이를 해결하기 위해 6-1-2의 Chaining Loaders Plugin을 만들어 사용했는데, 사용자 지정 esbuild 설정 파일(esbuild.config.js)이 처리 기회를 먼저 가져가 버리는 문제도 있었습니다.

 

예를 들어, hmr에서 모듈을 resolve할 때, 경로뿐만 아니라 external도 결정해야 하는데, 8-4에서 설명한 workspace 플러그인에선 경로만 처리하기 때문에, hmr 청크에 react-native가 다시 포함되는 문제가 발생했습니다.

 

이를 방지하기 위해 플러그인에 빌드 정보를 일부 노출해서 특정 상황에서만 작동하도록 했는데, 좀 무리수가 아닌가 싶습니다.

 

차라리 커스텀 인터페이스를 노출하는 것이 더 낫지 않을까 합니다.

8-6. 예제 실행시 yarn prepare가 필요한 이유

라이브러리로 사용되는 것을 가정하고 만든 프로젝트이기 때문에, 다른 프로젝트에 의존성으로 추가되면 npm(yarn) install을 할 때 자동으로 prepare 스크립트가 실행됩니다.

 

이때 자동으로 타입스크립트 코드가 CJS 코드로 변환됩니다.

 

그런데 workspace로 포함된 예제에서는 node_modules에 복사되지 않고, 바로 상위 폴더를 참조하면서 prepare 스크립트도 실행되지 않았습니다.

 

8-4와 동일한 방법으로 해결할 수 있지만, 예외적인 상황일 뿐이므로 자동화하지는 않았습니다.

8-7. 부가적인 기능들

리액트 네이티브는 코어에 어떤 기능을 추가하기 쉽도록 설계되어 있습니다.

 

6-2-3에서 소개한 HMRClient가 그렇습니다.

 

리액트 네이티브 코어에선 글로벌에서 HMRClient를 찾고, setup 메서드를 실행합니다. (위에서 설명했지만, 저는 여기서 웹소켓을 연결했었습니다.)

 

그리고 예제 코드의 console이 작동되지 않는다고 했었는데, 사실 구현 자체는 간단합니다.

 

6-1-4에서 InitializeCore가 실행된다고 했는데, 여기서 실행되는 setupDeveloperTools에서 기본 console 동작에 빨때를 꼽아 HMRClient 객체의 log 메서드에 연결시켜 두었습니다.

 

여기서 웹소켓을 통해 메세지를 전달하면 됩니다.

 

개발 서버의 상태도 DevLoadingView를 이용하면 간단하게 에뮬레이터에 표시할 수 있을 것 같습니다.

 

HMR 이외의 부분까지 추가되면 내용이 너무 복잡해질까봐 HMR 이외 부분은 다 뺐습니다.

 

번들링 부분은 너무 성의없이 했다는 생각이 듭니다.

 

esbuild만 사용했을 뿐이지 babel, resolve를 기존 기능을 가져다 썼기 때문에, 멀티 스래드를 사용한다 뿐이지 metro와 별 차이가 없습니다.

 

제대로 하려면 코드 babel 변환과 resolve를 go 언어로 작성된 esbuild를 활용하는 방법으로 마이그레이션해야 하는데, 많은 분석과 실험이 필요할 것 같습니다.

 

추가로 iOS도 코드상으론 고려했는데, 한 번도 실행시켜보지는 않았습니다. (문제가 발생하더라도 조금만 수정하면 될 것 같깉 하지만..)