Vue 代码构建

2022/11/3 Vue

# 开发环境

上面我们运行 npm run dev 得到了 Vue 的打包后的代码。我们看一下 package.json/scripts 里 dev 相关的代码

"scripts": {

  "dev": "rollup -w -c scripts/config.js --environment TARGET:full-dev --sourcemap",
  "dev:cjs": "rollup -w -c scripts/config.js --environment TARGET:runtime-cjs-dev",
  "dev:esm": "rollup -w -c scripts/config.js --environment TARGET:runtime-esm",
  "dev:ssr": "rollup -w -c scripts/config.js --environment TARGET:server-renderer",
  "dev:compiler": "rollup -w -c scripts/config.js --environment TARGET:compiler ",
  ...
}

Vue 开发环境构建的入口文件是 script/config.js,根据传入的参数不同,来构建不同的环境的代码。

简单看一下 config.js config

config 文件中,主要的内容是,根据命令行参数对构建配置做过滤。判断process.env.Target 如果有 Target,就导出genConfig(process.env.Target) 这个方法;如果没有就导出getBuildgetAllBuilds这两个方法。

主要就是 genConfig 这个方法:

function genConfig(name) {
  const opts = builds[name];
  const isTargetingBrowser = !(opts.transpile === false || opts.format === 'cjs');

  const config = {
    input: opts.entry,
    external: opts.external,
    plugins: [
      alias({
        entries: Object.assign({}, aliases, opts.alias),
      }),
      ts({
        tsconfig: path.resolve(__dirname, '../', 'tsconfig.json'),
        cacheRoot: path.resolve(__dirname, '../', 'node_modules/.rts2_cache'),
        tsconfigOverride: {
          compilerOptions: {
            // if targeting browser, target es5
            // if targeting node, es2017 means Node 8
            target: isTargetingBrowser ? 'es5' : 'es2017',
          },
          include: isTargetingBrowser ? ['src'] : ['src', 'packages/*/src'],
          exclude: ['test', 'test-dts'],
        },
      }),
    ].concat(opts.plugins || []),
    output: {
      file: opts.dest,
      format: opts.format,
      banner: opts.banner,
      name: opts.moduleName || 'Vue',
      exports: 'auto',
    },
    onwarn: (msg, warn) => {
      if (!/Circular/.test(msg)) {
        warn(msg);
      }
    },
  };

  // built-in vars
  const vars = {
    __VERSION__: version,
    __DEV__: `process.env.NODE_ENV !== 'production'`,
    __TEST__: false,
    __GLOBAL__: opts.format === 'umd' || name.includes('browser'),
  };
  // feature flags
  Object.keys(featureFlags).forEach((key) => {
    vars[`process.env.${key}`] = featureFlags[key];
  });
  // build-specific env
  if (opts.env) {
    vars['process.env.NODE_ENV'] = JSON.stringify(opts.env);
    vars.__DEV__ = opts.env !== 'production';
  }

  vars.preventAssignment = true;
  config.plugins.push(replace(vars));

  Object.defineProperty(config, '_name', {
    enumerable: false,
    value: name,
  });

  return config;
}

这个方法是根据入参 name,也就是命令行的参数,拿到 builds 里的相关设置,返回一个 rollup 构建的配置属性的 config

其中 builds 是 Vue 各种环境的打包配置,

const builds = {
  ...
  // Runtime+compiler development build (Browser)
  'full-dev': {
    entry: resolve('web/entry-runtime-with-compiler.ts'),
    dest: resolve('dist/vue.js'),
    format: 'umd',
    env: 'development',
    alias: { he: './entity-decoder' },
    banner
  },
  ...
}

上面是运行 npm run dev 时,TARGET = 'full-dev' 的配置。包括了入口文件,输出地址,构建格式(cjs: CommonJS 规范, es: ES Module 规范。 umd: UMD 规范。),当前的环境,以及别名等。

上面的这个full-dev 这个配置,入口是 resolve('web/entry-runtime-with-compiler.ts') , 来看一下resolvealiases 这两个

const aliases = require('./alias');
const resolve = (p) => {
  const base = p.split('/')[0];
  if (aliases[base]) {
    return path.resolve(aliases[base], p.slice(base.length + 1));
  } else {
    return path.resolve(__dirname, '../', p);
  }
};

resolve 这个方法是来把参数 p 根据 ‘/’ 分割成多个参数,根据第一个参数 base,从 aliases 里匹配对应的 'path'

const path = require('path');

const resolve = (p) => path.resolve(__dirname, '../', p);

module.exports = {
  vue: resolve('src/platforms/web/entry-runtime-with-compiler'),
  compiler: resolve('src/compiler'),
  core: resolve('src/core'),
  shared: resolve('src/shared'),
  web: resolve('src/platforms/web'),
  server: resolve('packages/server-renderer/src'),
  sfc: resolve('packages/compiler-sfc/src'),
};

aliases 就是一个 map,dev 环境下,base 的值是 web,所以对应的入口文件就是 :src/platforms/web/entry-runtime-with-compiler.ts

然后构建出来就是在 dist 目录下的 vue.js 了

# 生产环境构建

生产环境相关的命令

"scripts": {
  ...
  "build": "node scripts/build.js",
  "build:ssr": "npm run build -- runtime-cjs,server-renderer",
  "build:types": "rimraf temp && tsc --declaration --emitDeclarationOnly --outDir temp && api-extractor run && api-extractor run -c packages/compiler-sfc/api-extractor.json",
  ...
}

生产版本的构建,入口是 scripts/build.js, 我们来看一下 build.js

const fs = require('fs')
const path = require('path')
const zlib = require('zlib') // 提供gzip功能实现压缩
const rollup = require('rollup') // 打包
const terser = require('terser') // 压缩代码。

// 判断是否存在 dist 目录,如果不存在就创建一个 dist 目录
if (!fs.existsSync('dist')) {
  fs.mkdirSync('dist')
}

// 拿到所有 build 键值对对应的数组
let builds = require('./config').getAllBuilds()

// filter builds via command line arg
if (process.argv[2]) {
  const filters = process.argv[2].split(',')
  builds = builds.filter(b => {
    return filters.some(
      f => b.output.file.indexOf(f) > -1 || b._name.indexOf(f) > -1
    )
  })
}

build(builds)

function build(builds) {
  let built = 0
  const total = builds.length
  const next = () => {
    buildEntry(builds[built])
      .then(() => {
        built++
        if (built < total) {
          next()
        }
      })
      .catch(logError)
  }

  next()
}

function buildEntry(config) {
  const output = config.output
  const { file, banner } = output
  const isProd = /(min|prod)\.js$/.test(file)
  // 返回一个 promise
  return rollup
    .rollup(config)
    .then(bundle => bundle.generate(output)) // 解析一个bundle,然后生产code
    .then(async ({ output: [{ code }] }) => {
      // code 是一个对象,文件名为 key, 源码为 value
      // 生产环境会走 terser.minify 压缩
      if (isProd) {
        const { code: minifiedCode } = await terser.minify(code, {
          toplevel: true,
          compress: {
            pure_funcs: ['makeMap']
          },
          format: {
            ascii_only: true
          }
        })
        const minified = (banner ? banner + '\n' : '') + minifiedCode
        return write(file, minified, true)
      } else {
        return write(file, code)
      }
    })
}

// 目标文件 dest,内容 code,开启压缩 zip
function write(dest, code, zip) {
  return new Promise((resolve, reject) => {
    // 在运行时的控制台打印出蓝色的 log 日志,包括文件地址,大小等信息
    function report(extra) {
      console.log(
        blue(path.relative(process.cwd(), dest)) +
          ' ' +
          getSize(code) +
          (extra || '')
      )
      resolve()
    }

    // fs.writeFile 异步地将数据写入文件,如果文件已经存在,则替换该文件
    if (!fs.existsSync(path.dirname(dest))) {
      fs.mkdirSync(path.dirname(dest), { recursive: true })
    }
    fs.writeFile(dest, code, err => {
      if (err) return reject(err)
      if (zip) {
        // 开启gzip压缩
        zlib.gzip(code, (err, zipped) => {
          if (err) return reject(err)
          report(' (gzipped: ' + getSize(zipped) + ')')
        })
      } else {
        report()
      }
    })
  })
}

// 获取文件大小
function getSize(code) {
  return (code.length / 1024).toFixed(2) + 'kb'
}

function logError(e) {
  console.log(e)
}

// 蓝色信息
function blue(str) {
  return '\x1b[1m\x1b[34m' + str + '\x1b[39m\x1b[22m'
}

build.js 就主要执行了 build 方法,这个方法的主要作用就是遍历所有的配置,把所有的配置都构建出来。buildEntry 方法则是真正执行构建的方法,使用 rollup 来把多个配置类型的 Vue 构建出来,rollup的rollup方法会返回一个promise,构建出bundle之后会生成code,如果生产环境会压缩,调用write等方法,写入文件,在控制台打印信息。

打印

# runtime 和 complier

Vue 的构建配置中,有的是 runtime only 有的是 runtime + complier,这又有什么区别呢?

它们区别在于 runtime + compiler 是包含编译过程的,把编译代码放在运行时做。runtime only 需要结合 vue-template-compiler 事先将模版编译成 render 函数再执行 runtime only 运行起来会更快一些。

而 Vue cli 中使用的 runtime only的,因为在构建的时候,会有vue-template-complier来处理相关的模板。