阅读 4523

从零开始构建 vue3

前言

2019年10月5日凌晨,Vue 的作者尤雨溪公布了 Vue3 的源代码。当然,它暂时还不是完整的 Vue3,而是 pre-alpha 版,只完成了一些核心功能。github 命名为  vue-next ,寓意下一代 vue 。在笔者发文前,已经有很多大佬陆续发布了一些解读 Vue3 源码的文章。但是,本文并不打算再增加一篇解读源码的文章,而是以项目参与者的视角,通过动手实践,一步步理解和搭建自己的 Vue3 项目。因此,为了达到最佳效果,建议读者,一边阅读本文,一边打开终端跟着一步步动手实践。你将掌握所有构建 Vue3 所必须的知识。

在此之前,建议先将 nodejs 版本升级到 v10.0 以上,笔者测试过,低于 v10.0 以下版本会出现各种揪心的错误,笔者自己使用的是 v10.13.0。

一. 创建项目

1. 创建 github 仓库

2. 克隆仓库到本地

git clone https://github.com/gtvue/vue3.git
cd vue3
git log --oneline && tree -aI .git
复制代码

可以看到 github 已经帮我们创建了以下三个基础文件,并做了初始化提交。

f9fa484 (HEAD -> master, origin/master, origin/HEAD) Initial commit
.
├── .gitignore
├── LICENSE
└── README.md
复制代码

二. 参考 vue-next

1. 克隆 vue-next

cd ..
git clone https://github.com/vuejs/vue-next.git
复制代码

2. 查看 vue-next 目录结构

cd vue-next
tree --dirsfirst -aI ".git*|.vscode|*.lock" -C -L 1
复制代码

只展开第一级目录,除去 .git 开头,.vscode,以及 .lock 文件,可以看到主要有 3 个目录和 8 个文件。

.
├── .circleci
├── packages
├── scripts
├── .prettierrc
├── README.md
├── api-extractor.json
├── jest.config.js
├── lerna.json
├── package.json
├── rollup.config.js
└── tsconfig.json

3 directories, 8 files
复制代码

3. 3 个目录

# directories what is it ? how to use ?
1 .circleci 云端持续集成工具 CircleCI 配置目录 circleci.com
2 packages 源码目录 ——
3 scripts 构建脚本目录 ——

4. 8 个文件

# files what is it ? how to use ?
1 .prettierrc 代码格式化工具 prettier 的配置文件 prettier.io
2 README.md 项目介绍 ——
3 api-extractor.json TypeScript 的API提取和分析工具 api-extractor 的配置文件 api-extractor.com
4 jest.config.js JavaScript 测试框架 jest 的配置文件 jestjs.io
5 lerna.json JavaScript 多 package 项目管理工具 lerna 的配置文件 lerna.js.org
6 package.json npm 配置文件 docs.npmjs.com
7 rollup.config.js JavaScript 模块打包器 rollup 的配置文件 rollupjs.org rollupjs.com
8 tsconfig.json TypeScript 配置文件 tslang.cn typescriptlang.org

5. 回到初次提交

git checkout `git log --pretty=format:"%h" | tail -1`

git log --pretty=format:"'%an' commited at %cd : %s"
复制代码

显示,尤雨溪于 2018 年 9 月 19 日 中午 11 点 35 分首次提交了 vue-next 。时至今日已经过去了一年多。

'Evan You' commited at Wed Sep 19 11:35:38 2018 -0400 : init (graduate from prototype)
复制代码

不妨看看尤大在第一次创建项目时,都添加了那些文件。

$ tree --dirsfirst -aI ".git*|.vscode|*.lock" -C -L 1
.
├── packages
├── scripts
├── .prettierrc
├── lerna.json
├── package.json
├── rollup.config.js
└── tsconfig.json

2 directories, 5 files
复制代码

对比现在的目录结构,第一次提交的文件要干净一些,具体来说,少了持续集成工具 CircleCI ,测试工具 jest 和 API 提取工具 api-extractor 。只有源码及源码构建和包管理相关的文件。而这些正是整个项目最重要的部分,这里我们可以把它看作是要自己开发一个类似 vue3 的 JavaScript 库所需要的启动工程。可见这些文件对我们来说是非常的重要。为了不“改变历史”,我们不妨 checkout 出一个新的分支,以便尽情查阅。

git checkout -b InitialCommit
复制代码

6. package.json

了解 JS 项目最重要的文件莫过于 package.json ,它的作用相当于整个项目的总设计图。那么看下尤大在第一次提交时,package.json 到底有啥。

是不是感觉特别清爽,它简洁到只有4个字段。其中我们需要关心的是 scriptsdevDependencies 。构建脚本非常简单,除了熟悉的 devbuild,还有一个用于对项目源码所有 TypeScript 代码进行格式化的 lint 。开发依赖也是非常精简,是采用 TypeScript 开发,并用 Rollupjs 打包 Js ,最基本的依赖安装。构建脚本 devbuild 依然是尤大一直热衷的方式,即将所有构建逻辑放在两个 js 文件中,scripts/dev.jsscripts/build.js ,并用 node 解释执行。因此,要了解整个项目的核心构建过程,就需要去研究这两个文件的实现。

6.1 scripts/dev.js

启动开发模式的代码非常简单,只有10几行代码,实际就是使用 execa 执行项目里安装(node_modules)的可执行文件。函数原型为 execa(exefile, [arguments], [options]),返回一个 Promise 对象。

const execa = require('execa')
const { targets, fuzzyMatchTarget } = require('./utils')

const target = fuzzyMatchTarget(process.argv[2] || 'runtime-dom')

execa(
  'rollup',
  [
    '-wc',
    '--environment',
    `TARGET:${target},FORMATS:umd`
  ],
  {
    stdio: 'inherit'
  }
)
复制代码

因此,node scripts/dev.js 等效于在 package.json 中的 "dev": "rollup -wc --environment TARGET:[target],FORMATS:umd" , 其中,[target] 来自命令参数 node scripts/dev.js [target]

  • -wc: -w 和 -c 组合,-c 使用配置文件 rollup.config.js 打包 js ,-w 观测源文件变化,并自动重新打包
  • --environment: 设置传递到文件中的环境变量,可以在JS文件中,通过 process.ENV 读取,这里设置了两个环境变量,process.ENV.TARGET = process.argv[2] || 'runtime-dom'process.ENV.FORMATS = umd

了解更多 rollup 参数,参考rollup 命令行参数

6.2 scripts/build.js

一共70行代码,为了节省篇幅,这里只截取了主执行代码。这是一个异步立即调用函数,获取命令行 node scripts/build.js [target] 中 target 参数(可选)赋值给 target 变量,如果 target 不空,就单独构建 target ,为空,就构建所有 targets 。而所谓的 target 就是 vue packages/ 目录下的各个子 pacakge (和子目录名相同)。

const fs = require('fs-extra')
const path = require('path')
const zlib = require('zlib')
const chalk = require('chalk')
const execa = require('execa')
const dts = require('dts-bundle')
const { targets, fuzzyMatchTarget } = require('./utils')

const target = process.argv[2]

;(async () => {
  if (!target) {
    await buildAll(targets)
    checkAllSizes(targets)
  } else {
    await buildAll(fuzzyMatchTarget(target))
    checkAllSizes(fuzzyMatchTarget(target))
  }
})()

...
复制代码

这里 buildAll(targets) 就是一个简单的 for 循环:for (const target of targets) { await build(target) }。因此,构建的核心是 build(target) 函数。

async function build (target) {
  const pkgDir = path.resolve(`packages/${target}`)

  await fs.remove(`${pkgDir}/dist`)

  await execa('rollup', [
    '-c',
    '--environment',
    `NODE_ENV:production,TARGET:${target}`
  ], { stdio: 'inherit' })

  const dtsOptions = {
    name: target === 'vue' ? target : `@vue/${target}`,
    main: `${pkgDir}/dist/packages/${target}/src/index.d.ts`,
    out: `${pkgDir}/dist/index.d.ts`
  }
  dts.bundle(dtsOptions)
  console.log()
  console.log(chalk.blue(chalk.bold(`generated typings at ${dtsOptions.out}`)))

  await fs.remove(`${pkgDir}/dist/packages`)
}
复制代码

我们发现,构建部分和 scripts/dev.js 惊人地相似。也是使用 execa 调用 rollup,只是少了 -w 参数,即不需要监测源文件的变化。并且传递了了环境变量 process.ENV.NODE_ENV = production,表示是这生产构建。

7. rollup.config.js

通过分析构建脚本 scripts/dev.jsscripts/build.js ,我们知道了,不管是开发构建还是生产构建,最终都是使用 rollup -c rollup.config.js 的方式,使用配置文件 rollup.config.js 的配置来完成 JS 的构建打包。配置文件自身也是一个 JS 脚本,意味着里面也可以有很多逻辑代码,事实上,前文讲到的环境变量TARGET, FORMATS, NODE_ENV,也是用在这个文件中的。

if (!process.env.TARGET) {
  throw new Error('TARGET package must be specified via --environment flag.')
}

// 此处省略 n 行 ...

const inlineFromats = process.env.FORMATS && process.env.FORMATS.split(',')
const packageFormats = inlineFromats || packageOptions.formats || defaultFormats
const packageConfigs = packageFormats.map(format => createConfig(configs[format]))

if (process.env.NODE_ENV === 'production') {
  packageFormats.forEach(format => {
    if (format === 'cjs') {
      packageConfigs.push(createProductionConfig(format))
    }
    if (format === 'umd' || format === 'esm-browser') {
      packageConfigs.push(createMinifiedConfig(format))
    }
  })
}

module.exports = packageConfigs
复制代码

rollup 配置文件既可以是一个 ES 模块,也可以是一个 CommonJS 模块,这里使用的是后者。并且支持导出单个配置对象,或配置对象数组,这里导出的一个配置对象数组 packageConfigs ,这样做是为了一次打包多个模块或 package 。

rollup 配置文件参考 rollup 命令行接口-配置文件

8. TypeScript

你可能会问 TypeScript 在哪里? 事实上, TypeScript 是以 rollup 插件的形式使用的。 依然可以在 rollup 配置文件 rollup.config.js 创建配置对象函数 createConfig() 中找到它的踪影。

const ts = require('rollup-plugin-typescript2')

// 此处省略 n 行 ...

function createConfig(output, plugins = []) {
  // 此处省略 n 行 ...

  const tsPlugin = ts({
    check: process.env.NODE_ENV === 'production' && !hasTSChecked,
    tsconfig: path.resolve(__dirname, 'tsconfig.json'),
    cacheRoot: path.resolve(__dirname, 'node_modules/.rts2_cache'),
    tsconfigOverride: {
      compilerOptions: {
        declaration: process.env.NODE_ENV === 'production' && !hasTSChecked
      }
    }
  })

  return {
    plugins: [
      tsPlugin,
      ...plugins
    ]
  }
}
复制代码

顺藤摸瓜,我们发现了,TypeScript 插件 tsPlugin 指定了配置文件 tsconfig.json 。因此,要了解 rollup 打包 TypeScript 做了哪些配置,就可以"移步" tsconfig.json 文件了。关于 TypeScript 的配置可参考 tsconfig.json

9. packages

知道项目的构建打包方式,终于要说我们的构建目标(也是前文的 target) packages 了。我们知道 Vue 是由 lerna 管理的多 package (npm 包)项目。这些 pacakge 就存放在 packages 目录下,每个 pacakge 都是一个与包名相同的子目录。

tree -I *.md --dirsfirst -L 2 -C packages
复制代码

运行以下代码,尝试生产构建:

npm i && npm run build
复制代码

会发现在打包 observer 时会报错。错误在源码文件 packages/observer/src/autorun.ts 的第 110 行处变量定义。将const runners = new Set() 改成 const runners:Set<Autorun> = new Set() 。重新 npm run build

npm run build
tree -I "*.md|*.json|*.ts" --dirsfirst -L 2 -C packages
复制代码

正如前文 6.2 小节所说,如果不带任何参数运行 node scripts/build.jsnpm run build 的构建脚本)将构建打包所有 packages 。 如果单独打包某一 package ,就需要指定对应包名作为参数。在项目根目录的 package.json 文件 "scripts" 字段添加如下内容:

"build:core": "node scripts/build.js core",
"build:observer": "node scripts/build.js observer",
"build:runtime-dom": "node scripts/build.js runtime-dom",
"build:scheduler": "node scripts/build.js scheduler",
复制代码

尝试单独构建:

# 先移除已经构建的 dist 目录
rm -rf  packages/*/dist

npm run build:core
npm run build:observer
npm run build:runtime-dom
npm run build:scheduler
复制代码

10. lerna

虽然多次提到 Vue 是使用 lerna 管理的多 packages 项目。但是到目前为止,即使我们已经完成了所有 packages 的打包构建,依然没有看到 lerna 的用武之地。 事实上,正如我们所说,lerna 是用于管理项目里的多个 packages ,它并不参与构建。lerna 也并没有我们想象的那样复杂。这里引用一段官方的介绍:

Lerna is a tool that optimizes the workflow around managing multi-package repositories with git and npm.

翻译过来就是:lerna 是一个工作流优化工具,用于优化使用 gitnpm 来管理在同一个 git 仓库有多个 npm 包的项目的工作流(念起来拗口,但道理很简单)。 隐含的意思就是,即使我们不使用 lerna 我们依然可以通过 git 和 npm 来管理这样的多包仓库,但是当 packages 越来越多,各 packages 之间还相互依赖,这个工作流就会变得异常复杂。而 lerna 的出现就是让这一切变得和管理一个 package 一样的简单。

既然说到这,不妨就一探究竟,lerna 到底给 vue 项目带来那些便利。首先全局安装 lerna:

npm install --global lerna
复制代码

关于 lerna 命令行的使用可以参考 官网 。这里简单演示以下几个比较常用的命令(事实上这些基本就是 lerna 的全部)。

10.1 lerna init [--independent/-i]

用于在新项目中首次初始化 lerna 。它会在项目根目录下创建 package.json , lerna.json 文件和一个空目录 packages ,可选的 -i--independent 用于设置多个 pacakges 使用独立的版本,默认使用相同的版本。 当然 vue-next 已经初始化了,就无需再次运行,并且 vue-next 使用相同的版本,目前都是 3.0.0-alpha.1,共同的版本保存在 lerna.json 文件中。

10.2 lerna ls

列出项目中所有的 pacakges ,名称是各 pacakge 下的 package.jsonname 字段。

$ lerna ls
info cli using local version of lerna
lerna notice cli v3.17.0
@vue/core
@vue/observer
@vue/runtime-dom
@vue/scheduler
lerna success found 4 packages
复制代码

10.3 lerna bootstrap

这是 lerna 最重要的一个命令。用于在不 publish 到 npm 前,解决各 pacakages 之间相互依赖的问题。它会根据各 pacakge 下的 package.json 文件中依赖,创建本地包引用的符号连接,相当于 npm-link 的作用,当然比起单独在每个 package 中 link 本地依赖要简单得多。现在只需要运行一次命令,就能自动将所有 pacakges 依赖 link 起来。 这样我们就可以在每个 pacakage 的代码中,直接通过包名称,require 或 import 使用。

lerna bootstrap
复制代码

执行完后,就可以看到,依赖项目中其他 pacakge 的 pacake 目录下多了个 node_modules 目录,里面存储的不是实际包文件,而是一个本地 pacakge 的符号链接,因此也能节省多个 package 具有相同依赖时的磁盘空间。

10.4 lerna changed

检查自最近一次发布以来,有那些 pacakge 发生了改动。作用类似于 package 维度的 git-status

10.5 lerna diff [package?]

显示自最近一次发布以来,文件改动的内容。作用类似于 package 维度的 git-diff ,它会和 git-diff 一样显示文件更改的地方。 例如前文,我们对源码做了更改,可以看到如下结果:

当然,我们也可以指定看某个 package 的改动,只需要在命令后增加 pacakge 名称,注意不是目录名称,而是由 package.json 中的 name 字段定义的包名,例如:@vue/runtime-dom。读者可以自行尝试。

10.6 lerna publish

这个不用说了,就是 npm-publish 的多包发布版。

三. 构建自己的 vue3

1. 准备工作

我们已经仔细研究了一番 vue-next 的构建工程。接下来,我们可以参照它来构建自己的 vue3 。在这之前,我们先将前文对 vue-nextInitialCommit 分支改动做一次提交。

git add .
git commit -m "fix type error of autorun.ts and add some build scripts"
复制代码

现在在我们的工作目录下,有两个项目:vue-next 和 vue3。vue-next 是我们要参考的项目,vue3 是我们自己构建的项目。vue-next 项目有两个分支,master 和从第一次提交检出的 InitialCommit 分支,当然 InitialCommit 已经不是最初的那个分支,我们成功修复了一个 BUG,虽然改变了历史,但是无所谓,因为,我们的目的仅仅是一个参考,而不是合并进原来的历史。现在我们可以任意切换 master 分支和 InitialCommit 分支,以便根据需要参考不同地方的代码。

下面的步骤,我们都将以 vue-next 的 master 分支为参考。因此,先切换到 master 分支。

git checkout master
复制代码

2. lerna 初始化

cd ../vue3
lerna init
复制代码

lerna 自动创建了 package.jsonlerna.json 两个配置文件,以及存放项目所有包的 packages 目录,当然现在还是一个什么都没有的空目录。

tree -aI .git --dirsfirst -C
复制代码

在进行下一步之前,先提交一次。

git add . && git commit -m "Add lerna for managing packages"
复制代码

3. 构建工程

vue-next 根目录下的 package.json 中 “scripts” 复制到 vue3 的 package.json 中:

"scripts": {
    "dev": "node scripts/dev.js",
    "build": "node scripts/build.js",
    "size-runtime": "node scripts/build.js runtime-dom -p -f esm-browser",
    "size-compiler": "node scripts/build.js compiler-dom -p -f esm-browser",
    "size": "yarn size-runtime && yarn size-compiler",
    "lint": "prettier --write --parser typescript 'packages/**/*.ts'",
    "test": "jest"
}
复制代码

安装依赖:

yarn add -D typescript brotli chalk execa fs-extra lint-staged minimist prettier yorkie
yarn add -D rollup rollup-plugin-alias rollup-plugin-json rollup-plugin-replace rollup-plugin-terser rollup-plugin-typescript2
yarn add -D jest ts-jest @types/jest 
复制代码

拷贝整个 scripts 构建目录:

cd .. && cp -r vue-next/scripts vue3
复制代码

拷贝配置文件:

cp vue-next/{rollup.config.js,tsconfig.json,jest.config.js,.prettierrc} vue3
复制代码

4. 拷贝最新源码

cp -r vue-next/packages/* vue3/packages
复制代码

5. 最新源码的 package

$ cd vue3 && lerna ls
lerna notice cli v3.16.5
@vue/compiler-core
@vue/compiler-dom
@vue/reactivity
@vue/runtime-core
@vue/runtime-dom
@vue/runtime-test
@vue/server-renderer
vue
lerna success found 8 packages
$ tree -I "*.ts" -L 1 -C packages
packages
├── compiler-core
├── compiler-dom
├── reactivity
├── runtime-core
├── runtime-dom
├── runtime-test
├── server-renderer
├── shared
├── template-explorer
└── vue

10 directories, 0 files
复制代码

可以看到有 10 个目录,但只有 8 个 pacakge 。这是因为,lerna 只对包含 package.json 文件, 并且 "private" 字段不为 True 的目录才会识别成一个 package ,当然这对 npm 也是必须的。这 8 个目录以及对应的包名如下:

目录 package
compiler-core @vue/compiler-core
compiler-dom @vue/compiler-dom
reactivity @vue/reactivity
runtime-core @vue/runtime-core
runtime-dom @vue/runtime-dom
runtime-test @vue/runtime-test
server-renderer @vue/server-renderer
vue vue

6. 构建测试

创建本地 packages 的符号链接:

# rm -rf packages/*/{dist,node_modules}
lerna bootstrap
复制代码

启动开发模式:

yarn dev
复制代码

构建所有 packages :

yarn build
# tree -I "*.md|*.json|*.ts|__tests__|node_modules|*.html|*.js|*.css" --dirsfirst -L 2 -C packages
复制代码

查看打包文件大小:

yarn size-runtime
yarn size-compiler
yarn size
复制代码

代码规范检查:

yarn lint
复制代码

测试:

yarn test
复制代码

perfect ! 一切顺利 。

7. 提交

git add .
git commit -m "Start vue3"
复制代码

The End

恭喜!你现在已经有一个自己的 Vue3 项目。不断为自己的 Vue3 贡献代码吧,值得庆幸的是,你还可以持续跟进尤大进度,并且无缝“参考”最新代码,来来完善你的项目。

本文源码地址:github.com/gtvue/vue3

Thank you

编写本文耗费了笔者大量精力,如果本文让你有所收获,请不要吝惜点赞哦 👍

阅读原文


微信扫描二维码 获取最新技术原创

关注下面的标签,发现更多相似文章
评论