Skip to content

这篇文章主要阅读开发需要用到的命令和工具,即以下脚本涉及的部分。

json
{
  "dev": "node scripts/dev.js",
  "dev-esm": "node scripts/dev.js -if esm-bundler-runtime",
  "dev-compiler": "run-p \"dev template-explorer\" serve",
  "dev-sfc": "run-s dev-sfc-prepare dev-sfc-run",
  "dev-sfc-prepare": "node scripts/pre-dev-sfc.js || npm run build-compiler-cjs",
  "dev-sfc-serve": "vite packages/sfc-playground --host",
  "dev-sfc-run": "run-p \"dev compiler-sfc -f esm-browser\" \"dev vue -if esm-bundler-runtime\" \"dev server-renderer -if esm-bundler\" dev-sfc-serve",
  "serve": "serve",
  "open": "open http://localhost:5000/packages/template-explorer/local.html"
}
{
  "dev": "node scripts/dev.js",
  "dev-esm": "node scripts/dev.js -if esm-bundler-runtime",
  "dev-compiler": "run-p \"dev template-explorer\" serve",
  "dev-sfc": "run-s dev-sfc-prepare dev-sfc-run",
  "dev-sfc-prepare": "node scripts/pre-dev-sfc.js || npm run build-compiler-cjs",
  "dev-sfc-serve": "vite packages/sfc-playground --host",
  "dev-sfc-run": "run-p \"dev compiler-sfc -f esm-browser\" \"dev vue -if esm-bundler-runtime\" \"dev server-renderer -if esm-bundler\" dev-sfc-serve",
  "serve": "serve",
  "open": "open http://localhost:5000/packages/template-explorer/local.html"
}

首先我们会了解这些命令所涉及在 scripts/*里的脚本,接着在逐一分析每个命令所做的事情。

相关脚本

从以上命令可以看到涉及的脚本主要有两个:dev.jspre-dev-sfc.js

dev.js

此脚本主要是通过esbuild对要开发的包进行监听打包,修改即 rebuild。

为什么开发用 esbuild,生产用 rollup?

我们知道 esbuild 是通过Go编写的,可以直接使用编译后的打包代码进行打包处理,所以速度非常快,可以有更好的开发体验。但是 rollup 打包出来的体积更小,且有更好的tree-shaking。其实在生产环境的打包是用 rollup with esbuild的方式打包(结合rollup-plugin-esbuild)。

使用

在执行脚本时,直接提供要打包的包名(精准匹配)即可。e.g. node scripts/dev.js runtime-dom。包名默认为vue

此外还可以提供以下参数,脚本是通过minimist处理参数的。

  • -f: 指定打包的格式,包括global, esm-bundler, esm-browser, cjs以及vue包额外的格式global-runtime, esm-bundler-runtime, esm-browser-runtime;默认global;具体每种格式的含义在分析打包相关命令脚本时再展开。
  • -i: (inline)是否将依赖包一起打包进去;不提供即 false

    TIP

    false时只对cjsesm-bundler(-runtime)格式会 external 掉所有的依赖包。 其他global, esm-browser格式都需要 inline 所有依赖包以可以单独使用。具体可看打包格式的介绍

bash
$ node scripts/dev.js runtime-dom -if esm-bundler
# 包名的位置并不要紧
$ node scripts/dev.js -f cjs runtime-dom
$ node scripts/dev.js runtime-dom -if esm-bundler
# 包名的位置并不要紧
$ node scripts/dev.js -f cjs runtime-dom

最后通过pnpm link(或者其他包管理工具)进行本地 link 调试。

bash
# /Users/xxx/xxx/vue-core/packages/vue
$ pnpm dev vue -if esm-bundler

# 在用于调试的项目根目录下 e.g. /Users/xxx/xxx/my-project/
$ pnpm link /Users/xxx/xxx/vue-core/packages/vue

# /Users/xxx/xxx/my-project/下unlink
$ pnpm unlink /Users/xxx/xxx/vue-core/packages/vue
# /Users/xxx/xxx/vue-core/packages/vue
$ pnpm dev vue -if esm-bundler

# 在用于调试的项目根目录下 e.g. /Users/xxx/xxx/my-project/
$ pnpm link /Users/xxx/xxx/vue-core/packages/vue

# /Users/xxx/xxx/my-project/下unlink
$ pnpm unlink /Users/xxx/xxx/vue-core/packages/vue

这样vue包下的代码的改动就可以实时地体现在调试项目中了。

开发时打包的注意点

  • pnpm link之前,我们需要明确要开发的包的打包方式。比如上面例子我们是esm-bundler&&inline(所有其他依赖包一起打包进来了)的方式打包的。 那么我们在用于调试的项目中需要使用 esm 模块的方式且只需要安装vue即可,无需处理其他的依赖包比如runtime-dom等。
  • 在开发某个包之前,请确保已经通过pnpm build将其他依赖包打包好,因为在引入依赖包时都会根据以下在package.json的配置寻找对应的入口文件。

引包规则

vue包的package.json中指定入口文件的部分如下所示:

部分 package.json
json
{
  "main": "index.js",
  "module": "dist/vue.runtime.esm-bundler.js",
  "types": "dist/vue.d.ts",
  "unpkg": "dist/vue.global.js",
  "jsdelivr": "dist/vue.global.js",
  "files": [
    "index.js",
    "index.mjs",
    "dist",
    "compiler-sfc",
    "server-renderer",
    "jsx-runtime",
    "jsx.d.ts",
    "macros.d.ts",
    "macros-global.d.ts",
    "ref-macros.d.ts"
  ],
  "exports": {
    ".": {
      "types": "./dist/vue.d.ts",
      "import": {
        "node": "./index.mjs",
        "default": "./dist/vue.runtime.esm-bundler.js"
      },
      "require": "./index.js"
    },
    "./server-renderer": {
      "types": "./server-renderer/index.d.ts",
      "import": "./server-renderer/index.mjs",
      "require": "./server-renderer/index.js"
    },
    "./compiler-sfc": {
      "types": "./compiler-sfc/index.d.ts",
      "import": "./compiler-sfc/index.mjs",
      "require": "./compiler-sfc/index.js"
    },
    "./jsx-runtime": {
      "types": "./jsx-runtime/index.d.ts",
      "import": "./jsx-runtime/index.mjs",
      "require": "./jsx-runtime/index.js"
    },
    "./jsx-dev-runtime": {
      "types": "./jsx-runtime/index.d.ts",
      "import": "./jsx-runtime/index.mjs",
      "require": "./jsx-runtime/index.js"
    },
    "./jsx": "./jsx.d.ts",
    "./dist/*": "./dist/*",
    "./package.json": "./package.json",
    "./macros": "./macros.d.ts",
    "./macros-global": "./macros-global.d.ts",
    "./ref-macros": "./ref-macros.d.ts"
  }
}
{
  "main": "index.js",
  "module": "dist/vue.runtime.esm-bundler.js",
  "types": "dist/vue.d.ts",
  "unpkg": "dist/vue.global.js",
  "jsdelivr": "dist/vue.global.js",
  "files": [
    "index.js",
    "index.mjs",
    "dist",
    "compiler-sfc",
    "server-renderer",
    "jsx-runtime",
    "jsx.d.ts",
    "macros.d.ts",
    "macros-global.d.ts",
    "ref-macros.d.ts"
  ],
  "exports": {
    ".": {
      "types": "./dist/vue.d.ts",
      "import": {
        "node": "./index.mjs",
        "default": "./dist/vue.runtime.esm-bundler.js"
      },
      "require": "./index.js"
    },
    "./server-renderer": {
      "types": "./server-renderer/index.d.ts",
      "import": "./server-renderer/index.mjs",
      "require": "./server-renderer/index.js"
    },
    "./compiler-sfc": {
      "types": "./compiler-sfc/index.d.ts",
      "import": "./compiler-sfc/index.mjs",
      "require": "./compiler-sfc/index.js"
    },
    "./jsx-runtime": {
      "types": "./jsx-runtime/index.d.ts",
      "import": "./jsx-runtime/index.mjs",
      "require": "./jsx-runtime/index.js"
    },
    "./jsx-dev-runtime": {
      "types": "./jsx-runtime/index.d.ts",
      "import": "./jsx-runtime/index.mjs",
      "require": "./jsx-runtime/index.js"
    },
    "./jsx": "./jsx.d.ts",
    "./dist/*": "./dist/*",
    "./package.json": "./package.json",
    "./macros": "./macros.d.ts",
    "./macros-global": "./macros-global.d.ts",
    "./ref-macros": "./ref-macros.d.ts"
  }
}
  • main: 指定cjs方式引入时对应的入口文件,值为index.js

    index.js

    main指定的index.js里实际引入的就是打包好后的vue.cjs.(prod).js文件。因此我们需要确保dist目录下有对应的vue.cjs.(prod).js文件。

    js
    // index.js 文件内容
    'use strict'
    
    if (process.env.NODE_ENV === 'production') {
      module.exports = require('./dist/vue.cjs.prod.js')
    } else {
      module.exports = require('./dist/vue.cjs.js')
    }
    // index.js 文件内容
    'use strict'
    
    if (process.env.NODE_ENV === 'production') {
      module.exports = require('./dist/vue.cjs.prod.js')
    } else {
      module.exports = require('./dist/vue.cjs.js')
    }
  • module: 指定esm模块方式引入时对应的入口文件,值为dist/vue.runtime.esm-bundler.js

  • unpkg: 这是一个为了支持UNPKG的 CDN 而使用的,可以看到其指定的文件就是通过iife的方式打包的dist/vue.global.js;这样只要上传到了 npm,我们就可以通过unpkg.com/:package@:version/:file(e.g. https://unpkg.com/@vue/runtime-dom)的方式通过CDN去获取对应包的资源了。不过这里为啥不用dist/vue.global.prod.js?

  • jsdelivr: 和unpkg类似,也是为了支持jsdelivr。不过jsdelivr会代码压缩后返回vue.global.js

  • types: 指定包的类型声明文件。

  • files: 上传到 npm 需要包含的文件。这里解释的不错。

  • exports: 指定除了主入口文件之外还可以通过相对路径(相对包的根目录)指定引入其他的文件;其中可以通过types, importrequire来进一步指定不同模块方式下的引入对应文件。例如对于./server-renderer

    json
    {
      "./server-renderer": {
        "types": "./server-renderer/index.d.ts",
        "import": "./server-renderer/index.mjs",
        "require": "./server-renderer/index.js"
      }
    }
    {
      "./server-renderer": {
        "types": "./server-renderer/index.d.ts",
        "import": "./server-renderer/index.mjs",
        "require": "./server-renderer/index.js"
      }
    }
    js
    // esm: ./server-renderer/index.mjs
    import { renderToString } from 'vue/server-renderer'
    
    // cjs: ./server-renderer/index.js
    const { renderToString } = require('vue/server-renderer')
    // esm: ./server-renderer/index.mjs
    import { renderToString } from 'vue/server-renderer'
    
    // cjs: ./server-renderer/index.js
    const { renderToString } = require('vue/server-renderer')

对__esModule 标识的补充

在打包出来的.cjs.js中,我们可以看到前面有这么一句

js
Object.defineProperty(exports, '__esModule', { value: true })
Object.defineProperty(exports, '__esModule', { value: true })

这是因为在rollup配置了output.esModule: true,那么在打包时就会对打包的模块标记成es模块

为了弄清楚为什么要这个标记以及这个标记是给谁用的,我们来看一个例子:

TIP

以下是在 node 环境下跑的,为了使用es模块化,可以设置 package.json 的 type 为 module,或者命名为.mjs 文件;下面我们就直接写.js 后缀表示了

我们有index.js, es.jscjs.js文件,现在希望在es.jses模块化方式导出数据,在cjs.jsCommonJs方式导出数据,然后index.js引入数据。

导出的数据有默认数据name,以及具名数据age。不过到这里应该就有个疑问:CommonJs该如何以默认的方式导出数据?似乎没有。那我们就用module.exports.default来暂且代替试试吧。

js
import defaultName, { age } from './es.js'
console.log(defaultName, age) // 'leo' 16

import defaultName, { age } from './cjs.js'
console.log(defaultName, age) // { default: 'leo', age: 16 } 16
import defaultName, { age } from './es.js'
console.log(defaultName, age) // 'leo' 16

import defaultName, { age } from './cjs.js'
console.log(defaultName, age) // { default: 'leo', age: 16 } 16
js
const name = 'leo'
const age = 16

export default name
export { age }
const name = 'leo'
const age = 16

export default name
export { age }
js
const name = 'leo'
const age = 16

module.exports.default = name
module.exports.age = age
const name = 'leo'
const age = 16

module.exports.default = name
module.exports.age = age

结果发现从cjs.js导入的默认数据就是整个module.exports对象。出现这样的差异,是因为CommonJS 方式没有提供默认导出的方式

要解决这个问题,一些打包工具提出了用__esModule属性来对cjs模块进行标识,来告诉打包工具在用es模块化引入该cjs模块时,默认导出数据就是module.exports.default上的数据。所以上面的两个问题就有了解答:

  • 为什么要这个标记: CommonJs没有提供默认导出数据的方式,es模块化导入时会产生差异
  • 这个标记是给谁用的: 给打包工具识别的,所以本质是一种约定俗成的解决方案。

那么下面我们就用rollup来对其进行打包,看看能不能让cjs.js拥有默认导出的能力~

我们用到了@rollup/plugin-commonjs插件来处理es引入cjs的情况。

js
'use strict';

var cjs = {};

const name = 'leo';
const age = 16;

cjs.default = name;
var age_1 = cjs.age = age;

console.log(cjs, age_1); // { default: 'leo', age: 16 } 16

exports.age = age_1;
exports.name = cjs;
'use strict';

var cjs = {};

const name = 'leo';
const age = 16;

cjs.default = name;
var age_1 = cjs.age = age;

console.log(cjs, age_1); // { default: 'leo', age: 16 } 16

exports.age = age_1;
exports.name = cjs;
js
import commonjs from '@rollup/plugin-commonjs'

export default {
  input: 'index.js',
  output: {
    file: 'bundle.js',
    format: 'cjs' // 这个是最终打包成的格式,是什么对例子没有关系
    // esModule: true 设为true就对最终打包结果进行es标识,默认为false
  },
  plugins: [commonjs()]
}
import commonjs from '@rollup/plugin-commonjs'

export default {
  input: 'index.js',
  output: {
    file: 'bundle.js',
    format: 'cjs' // 这个是最终打包成的格式,是什么对例子没有关系
    // esModule: true 设为true就对最终打包结果进行es标识,默认为false
  },
  plugins: [commonjs()]
}

发现结果还是一样,因为还没有对cjs.js加标识呢。我们看看加了标识后的打包结果:

js
'use strict';

var cjs = {};

Object.defineProperty(cjs, '__esModule', { value: true }); // 一起打包进来了。。。不过起作用了

const name = 'leo';
const age = 16;

var _default = cjs.default = name; // 取了default属性作为默认导出
var age_1 = cjs.age = age;

console.log(_default, age_1); // 'leo' 16

exports.age = age_1;
exports.name = _default;
'use strict';

var cjs = {};

Object.defineProperty(cjs, '__esModule', { value: true }); // 一起打包进来了。。。不过起作用了

const name = 'leo';
const age = 16;

var _default = cjs.default = name; // 取了default属性作为默认导出
var age_1 = cjs.age = age;

console.log(_default, age_1); // 'leo' 16

exports.age = age_1;
exports.name = _default;
js
Object.defineProperty(exports, '__esModule', { value: true });

const name = 'leo'
const age = 16

module.exports.default = name
module.exports.age = age
Object.defineProperty(exports, '__esModule', { value: true });

const name = 'leo'
const age = 16

module.exports.default = name
module.exports.age = age

TIP

如果cjs.js里没有module.exports.default = name这一句,那么即使标识了也不会有默认导出的,行为会和没有标识一样,因为打包工具找不到default属性的值。

至此我们基本明白__esModule的作用了。

实现

具体的脚本代码并不多,主要是明确 esbuild 的配置,接着调用 esbuild 提供的 api 即可。

其中有几点特殊处理的情况:

  • vue-compat包打包的文件target名是vue而不是其本身。
    js
    const outfile = resolve(
    __dirname,
    `../packages/${target}/dist/${
      target === 'vue-compat' ? `vue` : target
    }.${postfix}.js`
    const outfile = resolve(
    __dirname,
    `../packages/${target}/dist/${
      target === 'vue-compat' ? `vue` : target
    }.${postfix}.js`
  • 在处理externals(打包时不包含的依赖包)时,compiler-sfc包需要额外 external 掉@vue/consolidate里的依赖包(devDependencies),但包含@vue/consolidate包本身(打包格式不是cjsesm-bundler(-runtime)格式时)。
脚本的书写方式
  • 通过// ts-check可以让对应的 js 文件纳入 ts 检查
js
// @ts-check
let x = 3
x = '' // Type 'string' is not assignable to type 'number'.ts(2322)
// @ts-check
let x = 3
x = '' // Type 'string' is not assignable to type 'number'.ts(2322)
  • 使用esm方式导入;有两种方式可以实现在node环境使用esm引包方式:1. 通过指定package.json里的typemodule;2. 通过命名文件后缀为.mjs;以下是在node环境使用esm时一些区别调整:
    • __dirname 不再适用,应该为import { dirname } from 'node:path' 里的dirname函数方法
      js
      import { dirname } from 'node:path'
      import { fileURLToPath } from 'node:url'
      // import.meta.url即当前执行脚本的fileUrl: e.g. file:///Users/xxx/xxx/index.js
      // fileURLToPath(import.meta.url) 之后就是 /Users/xxx/xxx/index.js
      const __dirname = dirname(fileURLToPath(import.meta.url))
      import { dirname } from 'node:path'
      import { fileURLToPath } from 'node:url'
      // import.meta.url即当前执行脚本的fileUrl: e.g. file:///Users/xxx/xxx/index.js
      // fileURLToPath(import.meta.url) 之后就是 /Users/xxx/xxx/index.js
      const __dirname = dirname(fileURLToPath(import.meta.url))
    • 动态导入可以通过createRequire方法实现
      js
      import { createRequire } from 'node:module'
      const require = createRequire(import.meta.url)
      const pkg = require('./package.json')
      import { createRequire } from 'node:module'
      const require = createRequire(import.meta.url)
      const pkg = require('./package.json')

pre-dev-sfc.js

此脚本主要是在执行dev-sfc之前确保其所依赖的包都已经打包好了。否则就执行npm run build-compiler-cjs以确保依赖包的打包工作已完成。

核心包开发命令

dev

对应于node scripts/dev.js

前面我们知道dev.js默认开发的包是vue,打包方式是global(iife),所以此命令就是用来开发global打包的vue的。

dev-esm

对应于node scripts/dev.js -if esm-bundler-runtime

即开发的包是vue,打包方式是esm-bundler-runtime,并且是将所有依赖包都打包进来的 inline 模式。

因为esm-bundler 格式下生产环境打包回external掉所有依赖包的,而这里提供inline选项就可以在开发时更好的调试。

辅助工具的开发

dev-compiler

对应于run-p \"dev template-explorer\" serve,用于开发编译器转译工具

其中run-p是指并行运行pnpm dev template-explorerpnpm serve命令,用的是npm-run-all这个工具。

  • pnpm dev template-explorer:相当于node scripts/dev.js template-explorer所以此时使用global格式打包的template-explorer这个包。
  • pnpm serve:基于项目根目录启动一个静态资源服务器,这样我们就可以直接访问本地进行开发http://localhost:5000/packages/template-explorer/local.html(对应的就是open命令)
    如何访问 template-explorer

    template-explorer包下有两个 html 文件:index.htmllocal.html。 其两者的区别就是 local 引用的资源是本地的,而 index 是用 CDN 的资源。

    html
    <title>Vue Template Explorer</title>
    <link
      rel="stylesheet"
      data-name="vs/editor/editor.main"
      href="https://unpkg.com/monaco-editor@0.20.0/min/vs/editor/editor.main.css" // index
      href="./node_modules/monaco-editor/min/vs/editor/editor.main.css" // local
    />
    <link rel="stylesheet" href="./style.css" />
    
    <div id="header"></div>
    <div id="source" class="editor"></div>
    <div id="output" class="editor"></div>
    
    <script src="https://unpkg.com/monaco-editor@0.20.0/min/vs/loader.js"></script> // index
    <script src="./node_modules/monaco-editor/min/vs/loader.js"></script> // local
    <script>
      require.config({
        paths: {
          vs: 'https://unpkg.com/monaco-editor@0.20.0/min/vs' // index
          vs: './node_modules/monaco-editor/min/vs' // local
        }
      })
    </script>
    <script src="./dist/template-explorer.global.js"></script>
    <script>
      require(['vs/editor/editor.main'], init /* injected by build */)
    </script>
    <title>Vue Template Explorer</title>
    <link
      rel="stylesheet"
      data-name="vs/editor/editor.main"
      href="https://unpkg.com/monaco-editor@0.20.0/min/vs/editor/editor.main.css" // index
      href="./node_modules/monaco-editor/min/vs/editor/editor.main.css" // local
    />
    <link rel="stylesheet" href="./style.css" />
    
    <div id="header"></div>
    <div id="source" class="editor"></div>
    <div id="output" class="editor"></div>
    
    <script src="https://unpkg.com/monaco-editor@0.20.0/min/vs/loader.js"></script> // index
    <script src="./node_modules/monaco-editor/min/vs/loader.js"></script> // local
    <script>
      require.config({
        paths: {
          vs: 'https://unpkg.com/monaco-editor@0.20.0/min/vs' // index
          vs: './node_modules/monaco-editor/min/vs' // local
        }
      })
    </script>
    <script src="./dist/template-explorer.global.js"></script>
    <script>
      require(['vs/editor/editor.main'], init /* injected by build */)
    </script>

    引用的打包资源就是./dist/template-explorer.global.js

dev-sfc

对应于run-s dev-sfc-prepare dev-sfc-run,用于开发在线演练场

其中run-s是指串行执行命令。

  • dev-sfc-prepare: 即在执行dev-sfc之前确保其所依赖的包都已经打包好了。否则就执行npm run build-compiler-cjs
  • dev-sfc-run: 这个命令对应于run-p \"dev compiler-sfc -f esm-browser\" \"dev vue -if esm-bundler-runtime\" \"dev server-renderer -if esm-bundler\" dev-sfc-serve。即并行执行开发sfc-playground依赖的compiler-sfc, vueserver-renderer这几个包,以及启动sfc-playground的开发服务器。

总结

  • 介绍了dev.js脚本的使用,可以监听并以指定参数打包对应包从而进行开发
  • 介绍了基本的引包规则,主要包括main, module, unpkgjsdelivr等字段
  • 补充说明了打包工具通过__esModule标识解决CommonJs没有默认导出的问题
  • 仓库编写脚本的方式
  • 简单介绍了每个scripts命令的用途