The introduction
React Native packages our JS code into a single file by default. When our React Native app gets big enough to take a long time to download all the JS code at once, we might think of optimizing it by loading it on demand. The first task of loading on demand is to split the code. This article will reveal the secrets of unpacking React Native step by step.
Metro is introduced
React Native uses Metro for packaging, so we need to get to know it first. The best way to study a packer is to look at its build.
Construction product analysis
Suppose we have the following code:
// index.js
const {say} = require('./utils')
say('I just can't tell her.')
// utils.js
exports.say = (word) = > console.log(word)
Copy the code
We package it using Metro (requires Metro and Metro-Core installed) :
metro build index.js --out bundle.js -z false
Copy the code
Where -z indicates whether the code is minify or not, we select False for ease of viewing.
The packaged file looks like this (the previous code has been omitted) :
. __d(function (
global,
_$$_REQUIRE,
_$$_IMPORT_DEFAULT,
_$$_IMPORT_ALL,
module.exports,
_dependencyMap
) {
'use strict'
const {say} = _$$_REQUIRE(_dependencyMap[0])
say('I just can't tell her.')},0[1]
)
__d(
function (
global,
_$$_REQUIRE,
_$$_IMPORT_DEFAULT,
_$$_IMPORT_ALL,
module.exports,
_dependencyMap
) {
'use strict'
exports.say = (word) = > console.log(word)
},
1,
[]
)
__r(0)
Copy the code
__d
__d means define, which is to define a module:
; (function (global) {
'use strict'
global[`${__METRO_GLOBAL_PREFIX__}__d`] = define
var modules = clear()
function clear() {
modules = Object.create(null)
return modules
}
function define(factory, moduleId, dependencyMap) {
if(modules[moduleId] ! =null) {
return
}
const mod = {
dependencyMap,
factory,
hasError: false.importedAll: EMPTY,
importedDefault: EMPTY,
isInitialized: false.publicModule: {
exports: {},
},
}
modules[moduleId] = mod
}
/ /...}) (typeofglobalThis ! = ='undefined'
? globalThis
: typeof global! = ='undefined'
? global
: typeof window! = ='undefined'
? window
: this
)
Copy the code
The define function takes 3 parameters, factory is the module factory method, moduleId is the moduleId, and dependencyMap is the module’s dependency list, which stores the ids of other modules it depends on. And then saved to modules.
__r
__r is a bit complicated, but we’ll follow through and end up with this function:
function loadModuleImplementation(moduleId, module) {...module.isInitialized = true
const {factory, dependencyMap} = module
try {
const moduleObject = module.publicModule
moduleObject.id = moduleId
factory(
global,
metroRequire,
metroImportDefault,
metroImportAll,
moduleObject,
moduleObject.exports,
dependencyMap
)
{
module.factory = undefined
module.dependencyMap = undefined
}
return moduleObject.exports
} catch (e) {
/ /...
} finally{}}Copy the code
This function gets the module from Modules via its module ID, and then executes the module’s factory method, which makes some modifications to the exports object passed in (for example, the following module added the say field to exports).
__d(
function (
global,
_$$_REQUIRE,
_$$_IMPORT_DEFAULT,
_$$_IMPORT_ALL,
module.exports,
_dependencyMap
) {
'use strict'
exports.say = (word) = > console.log(word)
},
1[]),Copy the code
LoadModuleImplementation finally returns the modified exports object.
Going back to the above example, we can now unpack it manually by simply splitting the packaged product into two files:
// utils.bundle.js. __d(function (
global,
_$$_REQUIRE,
_$$_IMPORT_DEFAULT,
_$$_IMPORT_ALL,
module.exports,
_dependencyMap
) {
'use strict'
const {say} = _$$_REQUIRE(_dependencyMap[0])
say('I just can't tell her.')},0[1])// index.bundle.js
__d(
function (
global,
_$$_REQUIRE,
_$$_IMPORT_DEFAULT,
_$$_IMPORT_ALL,
module.exports,
_dependencyMap
) {
'use strict'
exports.say = (word) = > console.log(word)
},
1,
[]
)
__r(0)
Copy the code
To use it, we just need to make sure that utils.bundle.js is loaded first and then index.bundle.js is loaded. So how to do that automatically? We need to take a look at some of the configurations that Metro packages.
The configuration file
Metro configuration files are as follows:
module.exports = {
/* general options */
resolver: {
/* resolver options */
},
transformer: {
/* transformer options */
},
serializer: {
/* serializer options */
},
server: {
/* server options */}},Copy the code
We only need to know about Serializer, which is the configuration related to build product output. We also need to know about createModuleIdFactory and processModuleFilter.
createModuleIdFactory
This configuration is used to generate the module ID, for example, when configured as follows:
module.exports = {
serializer: {
createModuleIdFactory() {
return (path) = > {
return path
}
},
},
}
Copy the code
The packaged module will have the file path as the module ID:
. __d(function (global, _$$_REQUIRE, _$$_IMPORT_DEFAULT, _$$_IMPORT_ALL, module.exports, _dependencyMap) {
"use strict";
const {
say
} = _$$_REQUIRE(_dependencyMap[0]);
say('I just can't tell her.');
},"/demo1/src/index.js"["/demo1/src/utils.js"]);
__d(function (global, _$$_REQUIRE, _$$_IMPORT_DEFAULT, _$$_IMPORT_ALL, module.exports, _dependencyMap) {
"use strict";
exports.say = word= > console.log(word);
},"/demo1/src/utils.js"[]); __r("/demo1/src/index.js");
Copy the code
processModuleFilter
This configuration is used to filter out the output of the module, again using an example:
// index.js
require('./unused.js')
const {say} = require('./utils')
say('I just can't tell her.')
// metro.config.js
module.exports = {
serializer: {
processModuleFilter: function (module) {
return module.path.indexOf('unused') = = = -1}},}Copy the code
The unused.js module will be removed from the build artifacts based on the configuration package above.
Unpacking of actual combat
Based on the above knowledge, we can finally start the actual unpacking. Here’s an example:
// utils.js
exports.say = (word) = > console.log(word)
// index1.js
const {say} = require('./utils')
say('I just can't tell her.')
// index2.js
const {say} = require('./utils')
say('Sparrow outside the window, talking on a telephone pole.')
Copy the code
You can see that utils.js is used in both index1.js and index2.js, and our purpose is to extract it separately into a package called base.js, Index1.js and index2.js are packaged as bundle1.js and bundle2.js, respectively. Base.js is loaded first, followed by bundle1.js and bundle2.js.
Let’s start by packaging base.js with the following packaging configuration:
const fs = require('fs')
module.exports = {
serializer: {
createModuleIdFactory: function () {
const moduleMap = {}
const projectRootPath = __dirname
const moduleFile = 'modules.txt'
if (fs.existsSync(moduleFile)) {
fs.unlinkSync(moduleFile)
}
return function (path) {
const modulePath = path.substr(projectRootPath.length + 1)
if(! moduleMap[modulePath]) { moduleMap[modulePath] =true
fs.appendFileSync(moduleFile, `${modulePath}\n`)}return modulePath
}
},
},
}
Copy the code
This configuration means that the relative path of the module in the project is used as the module ID, and the packaged module ID is recorded in modules.txt. After the package is completed, the content of the file is as follows:
src/utils.js
Copy the code
Next package bundle1.js, whose packaging configuration is:
const fs = require('fs')
const moduleFile = 'modules.txt'
const existModuleMap = {}
fs.readFileSync(moduleFile, 'utf8')
.toString()
.split('\n')
.forEach((path) = > {
existModuleMap[path] = true
})
function getParsedModulePath(path) {
const projectRootPath = __dirname
return path.substr(projectRootPath.length + 1)}module.exports = {
serializer: {
createModuleIdFactory: function () {
const currentModuleMap = {}
return function (path) {
const modulePath = getParsedModulePath(path)
if(! (existModuleMap[modulePath] || currentModuleMap[modulePath])) { currentModuleMap[modulePath] =true
fs.appendFileSync(moduleFile, `${modulePath}\n`)}return modulePath
}
},
processModuleFilter: function (modules) {
const modulePath = getParsedModulePath(modules.path)
if (existModuleMap[modulePath]) {
return false
}
return true}},}Copy the code
This configuration means using the relative path of the module in the project as the module ID and filtering out existing modules in modules.txt. Bundle1.js package results look like this:
The first line is some initialization code for the module system. This code already exists in base.js, so we need to delete this line and use line-replace to do this:
// package.json{..."scripts": {
"build:bundle1": "metro build src/index1.js --out bundle1.js -z true -c metro.bundle.js"."postbuild:bundle1": "node removeFirstLine.js ./bundle1.js",},"devDependencies": {
"line-replace": "^ 2.0.1." ",}}// removeFirstLine.js
const lineReplace = require('line-replace')
lineReplace({
file: process.argv[2].line: 1.text: ' '.addNewLine: false.callback: ({file, line, text, replacedText, error}) = > {
if(! error) {console.log(`Removed ${replacedText}`)}else {
console.error(error)
}
},
})
Copy the code
At this point, bundle1.js is packaged, and you can do the same for Bundle2.js.
Finally, verify that the packaged product works:
<! DOCTYPEhtml>
<html lang="en">
<body>
<script src="./base.js"></script>
<script src="./bundle1.js"></script>
<script src="./bundle2.js"></script>
</body>
</html>
Copy the code
conclusion
Unpacking React Native sounds like a fancy concept, but it’s easy to put it into practice. However, this is only the first step. Next we need to modify the Native code to truly implement the on-demand loading mentioned above, which will be revealed in the next article. Welcome to pay attention to the public account “front-end tour”, let us travel in the front-end ocean together.