从零开始构建 vue3

2019-10-23 17:52:45 浏览数 (1)

前言

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. 克隆仓库到本地

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

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

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

二. 参考 vue-next

1. 克隆 vue-next

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

2. 查看 vue-next 目录结构

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

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

代码语言:javascript复制
.
├── .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. 回到初次提交

代码语言:javascript复制
git checkout `git log --pretty=format:"%h" | tail -1`

git log --pretty=format:"'%an' commited at � : %s"

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

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

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

代码语言:javascript复制
$ 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 出一个新的分支,以便尽情查阅。

代码语言:javascript复制
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 对象。

代码语言:javascript复制
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 (和子目录名相同)。

代码语言:javascript复制
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) 函数。

代码语言:javascript复制
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,也是用在这个文件中的。

代码语言:javascript复制
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() 中找到它的踪影。

代码语言:javascript复制
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 都是一个与包名相同的子目录。

代码语言:javascript复制
tree -I *.md --dirsfirst -L 2 -C packages

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

代码语言:javascript复制
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

代码语言:javascript复制
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" 字段添加如下内容:

代码语言:javascript复制
"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",

尝试单独构建:

代码语言:javascript复制
# 先移除已经构建的 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:

代码语言:javascript复制
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 字段。

代码语言:javascript复制
$ 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 使用。

代码语言:javascript复制
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 分支改动做一次提交。

代码语言:javascript复制
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 分支。

代码语言:javascript复制
git checkout master

2. lerna 初始化

代码语言:javascript复制
cd ../vue3
lerna init

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

代码语言:javascript复制
tree -aI .git --dirsfirst -C

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

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

3. 构建工程

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

代码语言:javascript复制
"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"
}

安装依赖:

代码语言:javascript复制
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 构建目录:

代码语言:javascript复制
cd .. && cp -r vue-next/scripts vue3

拷贝配置文件:

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

4. 拷贝最新源码

代码语言:javascript复制
cp -r vue-next/packages/* vue3/packages

5. 最新源码的 package

代码语言:javascript复制
$ 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 的符号链接:

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

启动开发模式:

代码语言:javascript复制
yarn dev

构建所有 packages :

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

查看打包文件大小:

代码语言:javascript复制
yarn size-runtime
yarn size-compiler
yarn size

代码规范检查:

代码语言:javascript复制
yarn lint

测试:

代码语言:javascript复制
yarn test

perfect ! 一切顺利 。

7. 提交

代码语言:javascript复制
git add .
git commit -m "Start vue3"

The End

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

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

0 人点赞