本篇文章為大家帶來了關於JavaScript中模組化的相關知識,希望對大家有幫助。
眾所周知,js在前端開發中的地位。學好它,真的很重要。
下面這篇文章,介紹一下模組化。
到底什麼是模組化、模組化開發呢?
事實上模組化開發最終的目的是將程式分割成一個小的結構。
這個結構中寫屬於自己的邏輯程式碼,有自己的作用域,不會影響到其他的結構。
這個結構可以將自己希望暴露的變數、函數、物件等導出給其結構使用。
也可以透過某種方式,導入另外結構中的變數、函數、物件等。
上面說提到的結構,就是模組;依照這個結構劃分開發程式的過程,就是模組化開發的過程。
在網頁開發的早期,Brendan Eich開發JavaScript只是作為一種腳本語言,做一些簡單的表單驗證或動畫實作等,那時程式碼還是很少的:
這時候我們只需要講JavaScript程式碼寫到
並沒有必要放到多個檔案中來寫。
但隨著前端和JavaScript的快速發展,JavaScript程式碼變得越來越複雜了:
ajax的出現,前後端開發分離,意味著後端回傳資料後,我們需要透過JavaScript進行前端頁面的渲染。
SPA的出現,前端頁面變得更加複雜:包含前端路由、狀態管理等等一系列複雜的需求需要透過JavaScript來實現。
包括Node的實現,JavaScript編寫複雜的後端程序,沒有模組化是致命的硬傷。
所以,模組化已經是JavaScript一個非常迫切的需求。所以ES6(2015)才推出了自己的模組化方案。
在此之前,為了讓JavaScript支援模組化,湧現了很多不同的模組化規格:AMD、CMD、CommonJS等。
例如命名衝突的問題。
透過立即函數呼叫表達式(IIFE)來解決上面的問題。因為函數有自己的作用域,不會造成不同檔案命名衝突。
// a.js var moduleA = (function() { var name = "llm" var age = 22 var isFlag = true return { name: name, isFlag: isFlag } })()
// b.js var moduleB = (function() { var name = "zh" var isFlag = false return { name: name, isFlag: isFlag } })()
// 使用 moduleA.name moduleB.name
但是,我們其實帶來了新的問題:
我必須記得每一個模組中傳回物件的命名,才能在其他模組使用過程中正確的使用。
程式碼寫起來混亂不堪,每個檔案中的程式碼都需要包裹在一個匿名函數中來編寫。
在沒有適當的規範情況下,每個人、每家公司都可能會任意命名、甚至出現模組名稱相同的情況。
所以,我們會發現,雖然實作了模組化,但是我們的實作過於簡單,而且是沒有規範的。
我們需要製定一定的規格來約束每個人都按照這個規範去寫模組化的程式碼。這個規格應該包含核心功能:模組本身可以匯出暴露的屬性,模組又可以匯入自己需要的屬性。 JavaScript社群為了解決上面的問題,湧現一系列好用的規範,接下來我們就學習一些具有代表性的規範。
我們需要知道CommonJS是一個規範,最初提出來是在瀏覽器以外的地方使用,並且當時被命名為ServerJS,後來為了體現它的廣泛性,修改為CommonJS,平常我們也會簡稱為CJS。
Node是CommonJS在伺服器端一個具有代表性的實作。
Browserify是CommonJS在瀏覽器中的實作。
webpack打包工具具備對CommonJS的支援與轉換。
所以,Node中對CommonJS進行了支援和實現,讓我們在開發node的過程中可以方便的進行模組化開發。
在Node每一個js檔案都是一個單獨的模組。
這個模組包含CommonJS規格的核心變數:exports、module.exports、require。
我們可以使用這些變數來方便的進行模組化開發。
前面我們提到模組化的核心是導出和導入,Node中對其進行了實作:
exports和module. exports可以負責對模組中的內容進行導出。
require函數可以幫助我們匯入其他模組(自訂模組、系統模組、第三方函式庫模組)中的內容。
Node中對CommonJS進行了支援和實現,讓我們在開發node的過程中可以方便的進行模組化開發:
在Node中每一个js文件都是一个单独的模块。
这个模块中包括CommonJS规范的核心变量:exports、module.exports、require。
exports和module.exports可以负责对模块中的内容进行导出。
require函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容。
下面我们将来介绍exports、module.exports、require的使用。
exports是一个对象,我们可以在这个对象中添加很多个属性,添加的属性会导出。
我们也可以通过module.exports直接导出一个对象。
我们通过require()函数导入一个文件。并且该文件导出的变量。
下面来详细介绍一个module.exports。
CommonJS中是没有module.exports的概念的。
但是为了实现模块的导出,Node中使用的是Module的类,每一个模块都是Module的一个实例,也就是module。
所以在Node中真正用于导出的其实根本不是exports,而是module.exports。
因为module才是导出的真正实现者。
并且内部将exports赋值给module.exports。
该方式的导入导出有以下特点:
Node中的文件都运行在一个函数中。可以通过打印console.log(arguments.callee + "")来验证。
导入导出是值的引用,如果导出的是一个基本数据类型值,那么导出文件改变该值,然后导入文件该变量的值也不会变。
// a.js const obj = require("./b.js") console.log(obj) setTimeout(() => { obj.name = "llm" }, 1000)
// b.js const info = { name: "zh", age: 22, foo: function() { console.log("foo函数~") } } setTimeout(() => { console.log(info.name) // llm }, 2000) module.exports = info
他是通过require 函数来导入的,只有在执行js代码才会知道模块的依赖关系。
代码是同步执行的。
模块多次引入,只会加载一次。每个module内部会存在一个loaded来确定是否被加载过。
代码循环引入的时候,深度优先来加载模块。然后再广度优先。
我们现在已经知道,require是一个函数,可以帮助我们引入一个文件(模块)中导出的对象。
那么,require的查找规则是怎么样的呢?
详细查找规则,请访问这里
这里我总结比较常见的查找规则:导入格式如下:require(X)
模块在被第一次引入时,模块中的js代码会被运行一次
模块被多次引入时,会缓存,最终只加载(运行)一次
为什么只会加载运行一次呢?
这是因为每个模块对象module都有一个属性:loaded。为false表示还没有加载,为true表示已经加载。
如果有循环引入,那么加载顺序是什么?
如上图,Node采用的是深度优先算法:main -> aaa -> ccc -> ddd -> eee ->bbb
CommonJS加载模块是同步的:
同步的意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运行。
这个在服务器不会有什么问题,因为服务器加载的js文件都是本地文件,加载速度非常快。
如果将它应用于浏览器呢?
浏览器加载js文件需要先从服务器将文件下载下来,之后再加载运行。
那么采用同步的就意味着后续的js代码都无法正常运行,即使是一些简单的DOM操作。所以在浏览器中,我们通常不使用CommonJS规范。当然在webpack中使用CommonJS是另外一回事。因为它会将我们的代码转成浏览器可以直接执行的代码。
在早期为了可以在浏览器中使用模块化,通常会采用AMD或CMD。但是目前一方面现代的浏览器已经支持ES Modules,另一方面借助于webpack等工具可以实现对CommonJS或者ES Module代码的转换。AMD和CMD已经使用非常少了,所以这里我们进行简单的演练。
AMD主要是应用于浏览器的一种模块化规范:
AMD是Asynchronous Module Definition(异步模块定义)的缩写。它采用的是异步加载模块。
我们提到过,规范只是定义代码的应该如何去编写,只有有了具体的实现才能被应用。
AMD实现的比较常用的库是require.js和curl.js。
require.js的使用
定义HTML的script标签引入require.js和定义入口文件。data-main属性的作用是在加载完src的文件后会加载执行该文件
// index.html
//main.js require.config({ baseUrl: '', // 默认是main.js的文件夹路径 paths: { foo: "./foo" } }) require(["foo"], function(foo) { console.log("main:", foo) })
// foo.js define(function() { const name = "zh" const age = 22 function sum(num1, num2) { return num1 + num2 } return { name, age, sum } })
CMD规范也是应用于浏览器的一种模块化规范:
CMD 是Common Module Definition(通用模块定义)的缩写。它也采用了异步加载模块,但是它将CommonJS的优点吸收了过来。
AMD实现的比较常用的库是SeaJS。
SeaJS的使用
引入sea.js和使用主入口文件。
// index.html
//main.js define(function(require, exports, module) { const foo = require("./foo") console.log("main:", foo) })
// foo.js define(function(require, exports, module) { const name = "zh" const age = 22 function sum(num1, num2) { return num1 + num2 } // exports.name = name // exports.age = age module.exports = { name, age, sum } });
ES Module和CommonJS的模块化有一些不同之处:
一方面它使用了import和export关键字来实现模块化。
另一方面它采用编译期的静态分析,并且也加入了动态引用的方式。
export负责将模块内的内容导出。
import负责从其他模块导入内容。
采用ES Module将自动采用严格模式:use strict。
基本使用
// index.html
// foo.js let obj = { name: "zh", age: 22 } export default sum
// main.js import foo from './foo.js' console.log(foo)
在html文件加载入口文件的时候,需要指定type为module。
在打开html文件时,需要开启本地服务,而不能直接打开运行在浏览器上。
这个在MDN上面有给出解释:
你需要注意本地测试 — 如果你通过本地加载Html 文件 (比如一个 file:// 路径的文件), 你将会遇到 CORS 错误,因为Javascript 模块安全性需要。
你需要通过一个服务器来测试。
export关键字将一个模块中的变量、函数、类等导出。
我们希望将其他中内容全部导出,它可以有如下的方式:
方式一:在语句声明的前面直接加上export关键字。
export const name = "zh" export const age = 22
方式二:将所有需要导出的标识符,放到export后面的 {} 中。注意:这里的 {}里面不是ES6的对象字面量的增强写法,{}也不是表示一个对象的。所以: export {name: name},是错误的写法。
const name = "zh" const age = 22 function foo() { console.log("foo function") } export { name, age, foo }
方式三:导出时给标识符起一个别名。(基本没用,一般在导入文件中起别名)。然后在导入文件中就只能使用别名来获取。
export { name as fName, age as fAge, foo as fFoo }
import关键字负责从另外一个模块中导入内容。
导入内容的方式也有多种:
方式一:import {标识符列表} from '模块'。注意:这里的{}也不是一个对象,里面只是存放导入的标识符列表内容。
import { name, age } from "./foo.js"
方式二:导入时给标识符起别名。
import { name as fName, age as fAge } from './foo.js'
方式三:通过 * 将模块功能放到一个模块功能对象(a module object)上。然后通过起别名来使用。
import * as foo from './foo.js'
表示导入导出。
import { add, sub } from './math.js' import {otherProperty} from './other.js' export { add, sub, otherProperty }
等价于
// 导入的所有文件会统一被导出 export { add, sub } from './math.js' export {otherProperty} from './other.js'
等价于
export * from './math.js' export * from './other.js'
为什么要这样做呢?
在开发和封装一个功能库时,通常我们希望将暴露的所有接口放到一个文件中。 这样方便指定统一的接口规范,也方便阅读。这个时候,我们就可以使用export和import结合使用。
前面我们学习的导出功能都是有名字的导出(named exports):
在导出export时指定了名字。
在导入import时需要知道具体的名字。
还有一种导出叫做默认导出(default export)
// foo.js const name = "zh" cconst age = 22 export { name, // 或者这样的默认导出 // age as default } export default age
// 导入语句: 导入的默认的导出 import foo, {name} from './foo.js' console.log(foo, name) // 22 zh
默认导出export时可以不需要指定名字。
在导入时不需要使用 {},并且可以自己来指定名字。
它也方便我们和现有的CommonJS等规范相互操作。
注意:在一个模块中,只能有一个默认导出(default export)。
通过import加载一个模块,是不可以在其放到逻辑代码中的,比如:
if(true) { import foo from './foo.js' }
为什么会出现这个情况呢?
这是因为ES Module在被JS引擎解析时,就必须知道它的依赖关系。
由于这个时候js代码没有任何的运行,所以无法在进行类似于if判断中根据代码的执行情况。
但是某些情况下,我们确确实实希望动态的来加载某一个模块:
如果根据不同的条件,动态来选择加载模块的路径。
这个时候我们需要使用 import() 函数来动态加载。import函数返回的结果是一个Promise。
import("./foo.js").then(res => { console.log("res:", res.name) })
es11新增了一个属性。meta属性本身也是一个对象: { url: "当前模块所在的路径" }
console.log(import.meta)
ES Module是如何被浏览器解析并且让模块之间可以相互引用的呢?
ES Module的解析过程可以划分为三个阶段:
阶段一:构建(Construction),根据地址查找js文件,并且下载,将其解析成模块记录(Module Record)。
阶段二:实例化(Instantiation),对模块记录进行实例化,并且分配内存空间,解析模块的导入和导出语句,把模块指向对应的内存地址。
阶段三:运行(Evaluation),运行代码,计算值,并且将值填充到内存地址中。
階段一:
階段二和三:
所以,從上面可以看出在匯出檔案中,修改變數的值會影響到匯入檔案中的值。而且導入檔案被限制修改匯出檔案的值。
相關推薦:
以上是JavaScript高級語法之模組化(建議收藏)的詳細內容。更多資訊請關注PHP中文網其他相關文章!