你好,我是杨文坚。
我们课程的平台项目,在数据设计环节,把平台的数据划分成了三个数据维度:用户、物料和页面,对应的运营搭建平台功能就有三种功能维度:用户、物料和页面。
上节课我们学习了用户的注册和登录,也就是用户数据的操作,属于用户功能维度。从这节课开始,我们进入物料功能维度,对运营搭建平台的物料体系进行功能分析、方案设计、技术解构和代码实现。
“物料”功能,核心就是操作物料的静态资源和数据库数据。其中,物料静态资源指的是每个物料组件的产物,也就是JavaScript和CSS文件,可以独立在浏览器或者Node.js环境中进行渲染或者执行。
而运营搭建平台,底层功能里最核心就是用物料搭建页面。如何搭建,其实就是把这些物料的JavaScript和CSS文件组装起来运行。用前端技术视角看,就是用组件(物料)来组装页面。
想用组件组装成页面,首先要把组件模块化,方便后续组装,而且,要让组件在不同的环境(浏览器或者Node.js)里的运行,把组件编译成对应模块化格式后才能运行。那么前端组件有哪些模块化方案呢?
前端组件有哪些模块化方案?
前端组件模块化方案,其实归根结底就是JavaScript的模块化方案。因为不管是Vue.js组件、React.js组件或其他前端框架组件,最终要在浏览器或者Node.js环境运行,都需要编译成JavaScript代码。
那么,我们现在的关注点就是JavaScript的模块化方案。
JavaScript 作为一门“动态脚本语言”,在ES6草案确定前,没有“官方标准”的模快化方案。如果要对跨JavaScript文件的方法和数据进行“联动”,只能靠全局变量进行“通信联通”。后来经过技术社区的探索,基于ES5规范的JavaScript能力,实现了多种模块化方案。比较出名的有AMD模块化方案、CJS模块化方案。
JavaScrip 在ES6草案确定后,确定了在JavaScript原生语言层面的标准模块化方案,ES Modules,简称ESM。
很多人疑惑,ESM 既然是JavaScript语言官方的模块化方案,那ES6规范之前的“野生”模块化方案是不是就不适用了呢?其实并不是的,很多以前的模块化方案依然有适用场景,常用的主要有四个。
- ESM 模块化方案
- AMD模块化方案
- IIFE(全局变量)模块化方案
- CJS模块化方案
来看每个模块化方案的优缺点和使用场景,我也会用代码演示具体原理和实现过程。
1. ESM模块化方案
这个ES官方规范的模块化方案,在高版本浏览器和高版本Node.js环境下才能直接使用。Node.js在服务端开发可以统一约定使用高版本,但是浏览器是用户自行选择的,控制不了版本,所以ESM在实际工作中要面临浏览器兼容问题。
ESM的浏览器兼容情况(来自 https://caniuse.com/es6-module )。

ESM在Node.js环境下的支持情况截图(来自 https://nodejs.org/api/esm.html#modules-ecmascript-modules )。

总结一下ESM的几种场景特性。

我们这节课主要是学习组件的模块化方案,因为物料组价的拼装是在浏览器操作进行的,那就优先考虑在浏览器里使用。看一个代码案例,用ESM组装渲染一个Vue.js应用。
首先是代码的目录。
1 2 3 4 5 6
| . # packages/mock-cdn/demos/esm/ ├── index.html ├── index.js └── material ├── counter-decrease.js └── counter-increase.js
|
其中有两个物料组件 counter-decrease.js 和 counter-increase.js。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| import { h, ref, toDisplayString } from 'vue'; const Counter = { setup() { const num = ref(0); const click = () => { num.value -= 1; }; return () => { return h('div', { class: 'v-counter' }, [ h('div', { class: 'v-text' }, toDisplayString(num.value)), h( 'button', { class: 'v-btn', onClick: click }, '点击减1' ) ]); }; } }; export default Counter;
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| import { h, ref, toDisplayString } from 'vue'; const Counter = { setup() { const num = ref(0); const click = () => { num.value += 1; }; return () => { return h('div', { class: 'v-counter' }, [ h('div', { class: 'v-text' }, toDisplayString(num.value)), h( 'button', { class: 'v-btn', onClick: click }, '点击加1' ) ]); }; } }; export default Counter;
|
有了ESM物料产物,接下来就是物料的组装和渲染。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <html> <head> <meta charset="utf-8" /> <script type="importmap"> { "imports": { "vue": "/public/pkg/vue/3.2.45/dist/vue.runtime.esm-browser.js", "vue-router": "/public/pkg/vue/3.2.45/dist/vue.runtime.esm-browser.js" } } </script> </head> <body> <div id="app">页面加载中...</div> <script type="module" src="./index.js"></script> </body> </html>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| import { createApp, h } from 'vue'; const layout = { materials: [ { name: 'counter-increase' }, { name: 'counter-decrease' } ] };
async function runtime() { const children = []; for (const m of layout.materials) { const Module = await import(`/demos/esm/material/${m.name}.js`); children.push(h(Module?.default || Module)); } const App = h('div', {}, children); const app = createApp({ render() { return h(App, {}); } }); app.mount('#app'); }
runtime();
|
代码中,我们在index.html文件里用了importmap的特性,方便ESM里直接用 import vue的方式来调用;在index.js用import(),也就是ESM的异步调用模块方法,获取依赖的物料组件。最终基于Vue.js的非编译模式的语法,我们成功把两个组件组装一起渲染。
2. AMD模块化方案
AMD,全称是Asynchronous Module Definition,“异步模块定义”,是一种面向浏览器运行的模块化方案。
AMD在ES6还没出现之前,是ES5环境下常见用的模块化方案。这里提到的“异步模块”,是指按模块的依赖来异步加载AMD模块,等待依赖模块异步加载完,就开始执行主体代码。全程的运行时执行过程,都是基于ES5的语法能力来实现的。但是,AMD只是一种技术方案,也就是规范,具体技术实现需要根据规范,实现其运行时。目前主流的AMD技术框架有RequireJS。

看一个代码案例,基于RequireJS用AMD规范来组装物料,渲染一个Vue.js应用。
代码的目录。
1 2 3 4 5 6
| . # packages/mock-cdn/demos/amd/ ├── index.html ├── index.js └── material ├── counter-decrease.js └── counter-increase.js
|
两个AMD模块格式的物料组件 counter-decrease.js 和 counter-increase.js。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| define('counter-decrease', ['vue'], function (Vue) { const { h, ref, toDisplayString } = Vue; const Counter = { setup() { const num = ref(0); const click = () => { num.value -= 1; }; return () => { return h('div', { class: 'v-counter' }, [ h('div', { class: 'v-text' }, toDisplayString(num.value)), h( 'button', { class: 'v-btn', onClick: click }, '点击减1' ) ]); }; } }; return Counter; });
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| define('counter-increase', ['vue'], function (Vue) { const { h, ref, toDisplayString } = Vue; const Counter = { setup() { const num = ref(0); const click = () => { num.value += 1; }; return () => { return h('div', { class: 'v-counter' }, [ h('div', { class: 'v-text' }, toDisplayString(num.value)), h( 'button', { class: 'v-btn', onClick: click }, '点击加1' ) ]); }; } }; return Counter; });
|
AMD格式物料的组装和渲染。
1 2 3 4 5 6 7 8 9 10 11 12
| <html> <head> <meta charset="utf-8" /> <script src="/public/pkg/requirejs/2.3.6/require.js"></script> <script src="/public/pkg/vue/3.2.45/dist/vue.runtime.global.js"></script> </head> <body> <div id="app">页面加载中...</div> <script src="./index.js"></script> </body> </html>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| const layout = { materials: [ { name: 'counter-increase' }, { name: 'counter-decrease' } ] };
window.requirejs.config({ baseUrl: '/demos/amd/material/', paths: {} });
window.define('vue', [], function () { return window.Vue; });
function runtime() { window.require( ['vue', 'require', ...layout.materials.map((m) => m.name)], function (Vue, require) { const { createApp, h } = Vue; const children = []; for (const m of layout.materials) { const Module = require(m.name); children.push(h(Module?.default || Module)); } const App = h('div', {}, children); const app = createApp({ render() { return h(App, {}); } }); app.mount('#app'); } ); }
runtime();
|
从上面代码中,你可以看到AMD模块的运行依赖了RequireJS的运行,RequireJS提供一个define的全局方法,给开发者用来定义模块。
这里的RequireJS,是一种AMD模块化规范的代码实现,看定义过程。
1 2 3 4 5
| define('模块id', [ ], function( ) { })
|
RequireJS通过解析依赖,来异步加载所有的依赖的AMD模块,等待依赖加载完后,就执行模块主体代码。
3. IIFE模块化方案
IIFE,全称是Immediately Invoked Function Expression,“立即执行函数”的意思。如果要实现模块化,就需要在IIFE中,把代码挂载在全局变量上。
这也是早期JavaScript的模块化方案,根据不同环境,把模块全部挂载在对应环境的全局变量上,浏览器就挂载在“window”对象上,Node.js就挂载在global全局变量上。

关于IIFE全局变量模块化方案的代码案例,我们就不多讲了,非常简单,具体你可以参考代码案例所在目录(packages/mock-cdn/demos/iife/)。
4. CJS模块化方案
CJS,全称是CommonJS模块化规范,目前用的比较广泛是在Node.js环境里,因为Node.js刚诞生的时候,模块化方案是基于CommonJS规范来实现的,而且,CJS规范也是在ES6草案确定之前诞生的、兼容ES5的环境。

CJS比较适合在Node.js环境中使用,在Node.js服务端中拼接物料产物,在服务端组装成页面的HTML。
今天我们主要讲物料产物的编译和前端浏览器里物料运行,关于Node.js服务端物料产物组装,不多讲,后面会专门分析搭建平台的物料SSR渲染。
前端组件的四种常见模块化方案我们就都了解了,每个模块化方案都有优缺点和适用场景,可以根据不同场景,选择对应的模块化方案。
我们日常开发Vue.js组件都是TypeScript语法来开发的,那么如何编译成多种模块化格式呢?
Vue.js组件如何编译成多种模块?
目前主流的构建工具,比如Webpack、Rollup和Vite,都可以基于其插件体系,来把TypeScript的Vue.js组件编译成多种模块化格式文件。既然都可以渲染,我们就优先选用Vue.js官方标配的构建工具Vite,进行多种模块化编译。
目前,Vite默认支持ESM、AMD、IIFE和CJS。那么Vite如何实现AMD模块编译呢?
其实Vite底层生产模式是基于Rollup来进行编译的,我们可以强行传入AMD的配置来执行编译。看具体配置代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122
| import { build } from 'vite'; import pluginVue from '@vitejs/plugin-vue'; import pluginVueJsx from '@vitejs/plugin-vue-jsx'; import { resolvePackagePath, readFile } from './util'; import type { InlineConfig } from 'vite';
function getBuildConfig(opts: { name: string; version: string; dirName: string; libName: string; }): InlineConfig { const { dirName, libName } = opts; const config: InlineConfig = { plugins: [pluginVue(), pluginVueJsx()], build: { target: 'esnext', minify: false, emptyOutDir: true, outDir: resolvePackagePath(dirName, 'dist'), lib: { name: libName, entry: resolvePackagePath(dirName, 'src', 'index.ts'), formats: ['es', 'cjs', 'iife'], fileName: (format) => { if (format === 'es') { format = 'esm'; } return `index.${format}.js`; } }, rollupOptions: { preserveEntrySignatures: 'strict', external: ['vue', '@vue/components'], output: { globals: { vue: 'Vue', '@vue/components': 'MyVueComponents' }, assetFileNames: 'index[extname]' } } } }; return config; }
function getBuildAMDConfig(opts: { name: string; version: string; dirName: string; libName: string; }): InlineConfig { const { dirName, name, libName } = opts; const config: InlineConfig = { plugins: [pluginVue(), pluginVueJsx()], build: { minify: false, emptyOutDir: false, outDir: resolvePackagePath(dirName, 'dist'), lib: { name: libName, entry: resolvePackagePath(dirName, 'src', 'index.ts'), formats: ['amd'], fileName: () => { return 'index.amd.js'; } }, rollupOptions: { preserveEntrySignatures: 'strict', external: ['vue', '@vue/components'], output: { name: name, format: 'amd', amd: { id: name }, globals: { vue: 'vue', '@vue/components': '@vue/components' }, assetFileNames: 'index.amd[extname]' } } } }; return config; }
async function main() { console.log('执行物料编译...'); const materialList = [ { name: require('../packages/material-product-list/package.json').name, version: require('../packages/material-product-list/package.json') .version, dirName: 'material-product-list', libName: 'MyMaterialProdcutList' }, { name: require('../packages/material-banner-slides/package.json').name, version: require('../packages/material-banner-slides/package.json') .version, dirName: 'material-banner-slides', libName: 'MyMaterialBannerSlides' } ]; for (const opts of materialList) { console.log(`开始编译物料 ${opts.dirName}`); const config = getBuildConfig(opts); const configAMD = getBuildAMDConfig(opts); await build(config); await build(configAMD); } }
main();
|
在Vite编译代码中,我用一个Vite配置编译出ESM、CJS和IIFE的模块化格式代码,用另一个独立的Vite配置编译AMD模块代码。如果以后Vite不支持强行编译AMD的方式,我们可以独立用Rollup来进行编译。
在今天案例的 scripts/build-materials.ts 文件里,我就用一个Vite 编译脚本,编译了案例的两个物料组件,形成多种模块化格式,具体你可以课后看案例代码实现。
现在我们编译出了多种模块格式,在搭建平台项目中,如何实现物料产物的管理和运行呢?
如何管理和运行各种模块化的物料组件?
既然我们实现了物料,也就是Vue.js组件各种模块化格式的编译产物。接下来对产物的管理和运行,主要有四步。
- 第一步,把物料的Vue.js组件编译多种模块化格式。
- 第二步,把各种模块化文件发布到私有NPM站点或者CDN服务。
- 第三步,前台和后台服务各自读取CDN上的物料,进行拼接页面。
- 第四步,实现各种模块化代码执行的运行时。

我们逐步分析。
第一步,编译Vue.js组件,需要你根据自己企业的技术基建做选择,我为了演示方便,就在课程代码案例的monorepo仓库中,管理了两个物料组件material-banner-slides和material-product-list,然后进行Vite的构建编译。
第二步,把各种模块化文件发布到CDN上。如果你自己企业内部有私有NPM站点,就发布到私有NPM站点,如果有CDN服务,就发布到CDN服务上。某种意义上讲,NPM也是一种CDN服务。
这里你需要注意,每次发布物料模块文件,都需要修改组件的版本,因为每次生产的物料文件都是不会被覆盖的,会随着版本增加,方便后续物料出问题后可以进行快速回滚处理。
在课程的代码案例里,为了演示方便,我在monorepo项目中用一个子项目mock-cdn,模拟了一个CDN来存储公共物料。之后我把两个物料发布到monorepo的“模拟CDN” 中。
第三步,前台和后台服务,根据自己所需要用到的物料产物,各自读取CDN上的物料,方便后续浏览器获取对应服务的物料产物。课程的代码案例,我就从mock-cdn这个模拟CDN来获取公共物料文件。
第四步,实现各种模块化代码执行的运行时,根据页面的配置文件,也就是页面用了哪些物料,进行拼接渲染页面。
我在课程的代码案例中,基于前台场景,在浏览器中,实现了ESM模块化的运行时、AMD模块化运行时和IIFE模块化运行时。先定义了公用的页面物料配置数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| export interface LayoutConfig { materials: Array<{ name: string; globalName: string; version: string; props: Record<string, any>; }>; }
export const layout: LayoutConfig = { materials: [ { name: '@my/material-banner-slides', version: '0.1.0', globalName: 'MyMaterialBannerSlides', props: { style: { height: 100 } } }, { name: '@my/material-product-list', version: '0.1.0', globalName: 'MyMaterialProdcutList', props: {} } ] };
|
然后实现了一些公共工具方法和公用配置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
| export const CDN_BASE_URL = '/public/cdn/';
export async function loadMaterialStyle(params: { name: string; version: string; }) { const { name, version } = params; const materialId = `${name}/${version}`; if ( document.querySelectorAll(`style[data-material-id="${materialId}"]`) ?.length > 0 ) { return; } const url = `${CDN_BASE_URL}/material/${name}/${version}/index.css`; const text = await fetch(url).then((res) => res.text()); const style = document.createElement('style'); style.setAttribute('data-material-id', materialId); style.innerHTML = text; const head = document.querySelector('head') || document.querySelector('body') || document.querySelector('html');
head?.appendChild(style); }
export function loadScript(url: string): Promise<void> { return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = url; document.body.appendChild(script); script.onload = () => { resolve(); }; script.onerror = () => { reject(); }; }); }
export async function render(opts: { Vue: any; moduleMap: { [id: string]: any | { default: any } }; layout: LayoutConfig; }) { const { moduleMap, layout, Vue } = opts; const { h, createApp } = Vue; const children = layout.materials.map((item: any) => { return h( moduleMap[item.name]?.default || moduleMap[item.name], item?.props || {} ); }); const App = { setup() { return () => { return h('div', {}, children); }; } };
const app = createApp({ render() { return h(App, {}); } }); app.mount('#app'); }
|
最后实现ESM、AMD和IIFE三种模块格式在浏览器的运行时。
ESM物料组装运行时。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| import { CDN_BASE_URL, render, loadMaterialStyle, layout } from '../util';
async function loadMaterialESModule(params: { name: string; version: string }) { const { name, version } = params; return import( `${CDN_BASE_URL}material/${name}/${version}/index.esm.js` ); }
async function loadESModule(name: string) { return import( `${name}` ); }
async function runtime() { const moduleMap: any = {}; for (const item of layout.materials) { const { name, version } = item; const Module = await loadMaterialESModule({ name, version }); await loadMaterialStyle({ name, version }); moduleMap[name] = Module; } const Vue: any = await loadESModule('vue'); await render({ Vue, moduleMap, layout }); }
runtime();
|
AMD物料组装运行时。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| import { CDN_BASE_URL, render, loadMaterialStyle, layout } from '../util';
async function runtime() { const paths: Record<string, string> = {}; layout.materials.forEach((m) => { paths[m.name] = `material/${m.name}/${m.version}/index.amd`; }); window.requirejs.config({ baseUrl: CDN_BASE_URL, paths }); window.require( ['vue', 'require', ...layout.materials.map((m) => m.name)], (Vue: any, require: any) => { const moduleMap: any = {}; for (const m of layout.materials) { const { name, version } = m; loadMaterialStyle({ name, version }); moduleMap[name] = require(name); } render({ Vue, moduleMap, layout }); } ); }
runtime();
|
这里要注意一点,AMD运行时需要依赖RequireJS,来实现AMD模块的加载和运行。
IIFE物料组装运行时。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| import { CDN_BASE_URL, render, loadMaterialStyle, layout, loadScript } from '../util';
async function runtime() { const moduleMap: any = {}; for (const item of layout.materials) { const { name, version, globalName } = item; await loadScript( `${CDN_BASE_URL}/material/${name}/${version}/index.iife.js` ); await loadMaterialStyle({ name, version }); moduleMap[name] = window[globalName] as any; }
const Vue: any = window.Vue; await render({ Vue, moduleMap, layout }); }
runtime();
|
在所有组装物料的运行时代码中,你要注意,我们需要在运行时中,用JavaScript来手动加载CSS文件。这个CSS是没有模块化区分的,面向所有模块化格式,都是通用的。
代码中最终组装物料的效果图。

总结
今天我们学习了前端组件的模块化和运营搭建平台物料的产物管理,也就是Vue.js组件的模块化管理,为后面物料搭建页面打好基础。
总结一下不同模块格式的优缺点。

如果现在需要你做个终极选择,我们平台项目中要选择哪种模块方式呢?
答案是全都要。因为浏览器端把握在用户手里,我们无法预测实际代码在运行过程中会出现什么兼容问题,如果平台渲染能支持多重模块格式,就意味着可以做一些优化策略,在低版本浏览器中,就可以优先选择对应能支持的模块格式。
在实现运营搭建平台的物料产物管理时,有两点要注意:
- 平台不是独立的一个工程,你需要根据自身企业技术基建,进行工程能力整合,例如对企业内部的CDN服务或者NPM私有服务的对接。
- 物料产物需要版本化管理,也就是Vue.js组件每次迭代编译,都需要发布一个新版本,方便出问题后快速回滚线上代码。
思考题
前台场景运行页面时,通过ESM或者AMD格式进行异步加载物料的代码文件,如果页面依赖的物料变多了,物料文件请求也会变多,这会影响页面打开时间吗?有什么办法可以提高页面打开时间吗?
欢迎留言参与讨论,我们下节课再见。