<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          剖析 Vue CLI 實現(xiàn)原理

          共 56739字,需瀏覽 114分鐘

           ·

          2021-01-15 21:55

          若微信中閱讀體驗不佳,可點擊閱讀原文在 PC 端閱讀。

          Vue CLI 是一個基于 Vue.js 進行快速開發(fā)的完整系統(tǒng),提供了終端命令行工具、零配置腳手架、插件體系、圖形化管理界面等。本文暫且只分析項目初始化部分,也就是終端命令行工具的實現(xiàn)。

          0. 用法

          用法很簡單,每個 CLI 都大同小異:

          npm install -g @vue/cli
          vue create vue-cli-test

          目前 Vue CLI 同時支持 Vue 2 和 Vue 3 項目的創(chuàng)建(默認配置)。

          上面是 Vue CLI 提供的默認配置,可以快速地創(chuàng)建一個項目。除此之外,也可以根據(jù)自己的項目需求(是否使用 Babel、是否使用 TS 等)來自定義項目工程配置,這樣會更加的靈活。

          選擇完成之后,敲下回車,就開始執(zhí)行安裝依賴、拷貝模板等命令...

          看到 Successfully 就是項目初始化成功了。

          vue create  命令支持一些參數(shù)配置,可以通過 vue create --help  獲取詳細的文檔:

          用法:create [options] 
               
                

          選項:
            -p, --preset         忽略提示符并使用已保存的或遠程的預(yù)設(shè)選項
            -d, --default                   忽略提示符并使用默認預(yù)設(shè)選項
            -i, --inlinePreset         忽略提示符并使用內(nèi)聯(lián)的 JSON 字符串預(yù)設(shè)選項
            -m, --packageManager < command>  在安裝依賴時使用指定的 npm 客戶端
            -r, --registry              在安裝依賴時使用指定的 npm registry
            -g, --git [message]             強制 / 跳過 git 初始化,并可選的指定初始化提交信息
            -n, --no-git                    跳過 git 初始化
            -f, --force                     覆寫目標目錄可能存在的配置
            -c, -- clone                     使用 git  clone 獲取遠程預(yù)設(shè)選項
            -x, --proxy                     使用指定的代理創(chuàng)建項目
            -b, --bare                      創(chuàng)建項目時省略默認組件中的新手指導信息
            -h, -- help                      輸出使用幫助信息

          具體的用法大家感興趣的可以嘗試一下,這里就不展開了,后續(xù)在源碼分析中會有相應(yīng)的部分提到。

          1. 入口文件

          本文中的 vue cli 版本為 4.5.9。若閱讀本文時存在 break change,可能就需要自己理解一下啦

          按照正常邏輯,我們在 package.json 里找到了入口文件:

          {
            "bin": {
              "vue""bin/vue.js"
            }
          }

          bin/vue.js 里的代碼不少,無非就是在 vue  上注冊了 create / add / ui  等命令,本文只分析 create  部分,找到這部分代碼(刪除主流程無關(guān)的代碼后):

          // 檢查 node 版本
          checkNodeVersion(requiredVersion, '@vue/cli');

          // 掛載 create 命令
          program.command('create  ' ).action((name, cmd) => {
            // 獲取額外參數(shù)
            const options = cleanArgs(cmd);
            // 執(zhí)行 create 方法
            require('../lib/create')(name, options);
          });

          cleanArgs  是獲取 vue create  后面通過 -  傳入的參數(shù),通過 vue create --help 可以獲取執(zhí)行的參數(shù)列表。

          獲取參數(shù)之后就是執(zhí)行真正的 create  方法了,等等仔細展開。

          不得不說,Vue CLI 對于代碼模塊的管理非常細,每個模塊基本上都是單一功能模塊,可以任意地拼裝和使用。每個文件的代碼行數(shù)也都不會很多,閱讀起來非常舒服。

          2. 輸入命令有誤,猜測用戶意圖

          Vue CLI 中比較有意思的一個地方,如果用戶在終端中輸入 vue creat xxx  而不是 vue create xxx,會怎么樣呢?理論上應(yīng)該是報錯了。

          如果只是報錯,那我就不提了??纯唇Y(jié)果:

          終端上輸出了一行很關(guān)鍵的信息 Did you mean create,Vue CLI 似乎知道用戶是想使用 create  但是手速太快打錯單詞了。

          這是如何做到的呢?我們在源代碼中尋找答案:

          const leven = require('leven');

          // 如果不是當前已掛載的命令,會猜測用戶意圖
          program.arguments('').action(cmd => {
            suggestCommands(cmd);
          });

          // 猜測用戶意圖
          function suggestCommands(unknownCommand{
            const availableCommands = program.commands.map(cmd => cmd._name);

            let suggestion;

            availableCommands.forEach(cmd => {
              const isBestMatch =
                leven(cmd, unknownCommand) < leven(suggestion || '', unknownCommand);
              if (leven(cmd, unknownCommand) < 3 && isBestMatch) {
                suggestion = cmd;
              }
            });

            if (suggestion) {
              console.log(`  ` + chalk.red(`Did you mean ${chalk.yellow(suggestion)}?`));
            }
          }

          代碼中使用了 leven 了這個包,這是用于計算字符串編輯距離算法的 JS 實現(xiàn),Vue CLI 這里使用了這個包,來分別計算輸入的命令和當前已掛載的所有命令的編輯舉例,從而猜測用戶實際想輸入的命令是哪個。

          小而美的一個功能,用戶體驗極大提升。

          3. Node 版本相關(guān)檢查

          3.1 Node 期望版本

          和 create-react-app  類似,Vue CLI 也是先檢查了一下當前 Node 版本是否符合要求:

          • 當前 Node 版本: process.version
          • 期望的 Node 版本: require("../package.json").engines.node

          比如我目前在用的是 Node v10.20.1 而 @vue/cli 4.5.9  要求的 Node 版本是 >=8.9,所以是符合要求的。

          3.2 推薦 Node LTS 版本

          在 bin/vue.js  中有這樣一段代碼,看上去也是在檢查 Node 版本:

          const EOL_NODE_MAJORS = ['8.x''9.x''11.x''13.x'];
          for (const major of EOL_NODE_MAJORS) {
            if (semver.satisfies(process.version, major)) {
              console.log(
                chalk.red(
                  `You are using Node ${process.version}.\n` +
                    `Node.js ${major} has already reached end-of-life and will not be supported in future major releases.\n` +
                    `It's strongly recommended to use an active LTS version instead.`
                )
              );
            }
          }

          可能并不是所有人都了解它的作用,在這里稍微科普一下。

          簡單來說,Node 的主版本分為奇數(shù)版本偶數(shù)版本。每個版本發(fā)布之后會持續(xù)六個月的時間,六個月之后,奇數(shù)版本將變?yōu)?nbsp;EOL 狀態(tài),而偶數(shù)版本變?yōu)?**Active LTS **狀態(tài)并且長期支持。所以我們在生產(chǎn)環(huán)境使用 Node 的時候,應(yīng)該盡量使用它的 LTS 版本,而不是 EOL 的版本。

          EOL 版本:A End-Of-Life version of Node LTS 版本: A long-term supported version of Node

          這是目前常見的 Node 版本的一個情況:

          解釋一下圖中幾個狀態(tài):

          • CURRENT:會修復(fù) bug,增加新特性,不斷改善
          • ACTIVE:長期穩(wěn)定版本
          • MAINTENANCE:只會修復(fù) bug,不會再有新的特性增加
          • EOL:當進度條走完,這個版本也就不再維護和支持了

          通過上面那張圖,我們可以看到,Node 8.x 在 2020 年已經(jīng) EOL,Node 12.x 在 2021 年的時候也會進入 **MAINTENANCE **狀態(tài),而 Node 10.x 在 2021 年 4、5 月的時候就會變成 EOL。

          Vue CLI 中對當前的 Node 版本進行判斷,如果你用的是 EOL 版本,會推薦你使用 LTS 版本。也就是說,在不久之后,這里的應(yīng)該判斷會多出一個 10.x,還不快去給 Vue CLI 提個 PR(手動狗頭)。

          4. 判斷是否在當前路徑

          在執(zhí)行 vue create  的時候,是必須指定一個 app-name ,否則會報錯: Missing required argument  。

          那如果用戶已經(jīng)自己創(chuàng)建了一個目錄,想在當前這個空目錄下創(chuàng)建一個項目呢?當然,Vue CLI 也是支持的,執(zhí)行 vue create .  就 OK 了。

          lib/create.js  中就有相關(guān)代碼是在處理這個邏輯的。

          async function create(projectName, options{
            // 判斷傳入的 projectName 是否是 .
            const inCurrent = projectName === '.';
            // path.relative 會返回第一個參數(shù)到第二個參數(shù)的相對路徑
            // 這里就是用來獲取當前目錄的目錄名
            const name = inCurrent ? path.relative('../', cwd) : projectName;
            // 最終初始化項目的路徑
            const targetDir = path.resolve(cwd, projectName || '.');
          }

          如果你需要實現(xiàn)一個 CLI,這個邏輯是可以拿來即用的。

          5. 檢查應(yīng)用名

          Vue CLI 會通過 validate-npm-package-name  這個包來檢查輸入的 projectName 是否符合規(guī)范。

          const result = validateProjectName(name);
          if (!result.validForNewPackages) {
            console.error(chalk.red(`Invalid project name: "${name}"`));
            exit(1);
          }

          對應(yīng)的 npm 命名規(guī)范可以見:Naming Rules

          6. 若目標文件夾已存在,是否覆蓋

          這段代碼比較簡單,就是判斷 target  目錄是否存在,然后通過交互詢問用戶是否覆蓋(對應(yīng)的是操作是刪除原目錄):

          // 是否 vue create -m
          if (fs.existsSync(targetDir) && !options.merge) {
            // 是否 vue create -f
            if (options.force) {
              await fs.remove(targetDir);
            } else {
              await clearConsole();
              // 如果是初始化在當前路徑,就只是確認一下是否在當前目錄創(chuàng)建
              if (inCurrent) {
                const { ok } = await inquirer.prompt([
                  {
                    name'ok',
                    type'confirm',
                    message`Generate project in current directory?`,
                  },
                ]);
                if (!ok) {
                  return;
                }
              } else {
                // 如果有目標目錄,則詢問如何處理:Overwrite / Merge / Cancel
                const { action } = await inquirer.prompt([
                  {
                    name'action',
                    type'list',
                    message`Target directory ${chalk.cyan(
                      targetDir
                    )}
           already exists. Pick an action:`
          ,
                    choices: [
                      { name'Overwrite'value'overwrite' },
                      { name'Merge'value'merge' },
                      { name'Cancel'valuefalse },
                    ],
                  },
                ]);
                // 如果選擇 Cancel,則直接中止
                // 如果選擇 Overwrite,則先刪除原目錄
                // 如果選擇 Merge,不用預(yù)處理啥
                if (!action) {
                  return;
                } else if (action === 'overwrite') {
                  console.log(`\nRemoving ${chalk.cyan(targetDir)}...`);
                  await fs.remove(targetDir);
                }
              }
            }
          }

          7. 整體錯誤捕獲

          在 create  方法的最外層,放了一個 catch  方法,捕獲內(nèi)部所有拋出的錯誤,將當前的 spinner  狀態(tài)停止,退出進程。

          module.exports = (...args) => {
            return create(...args).catch(err => {
              stopSpinner(false); // do not persist
              error(err);
              if (!process.env.VUE_CLI_TEST) {
                process.exit(1);
              }
            });
          };

          8. Creator 類

          在 lib/create.js  方法的最后,執(zhí)行了這樣兩行代碼:

          const creator = new Creator(name, targetDir, getPromptModules());
          await creator.create(options);

          看來最重要的代碼還是在 Creator  這個類中。

          打開 Creator.js  文件,好家伙,500+ 行代碼,并且引入了 12 個模塊。當然,這篇文章不會把這 500 行代碼和 12 個模塊都理一遍,沒必要,感興趣的自己去看看好了。

          本文還是梳理主流程和一些有意思的功能。

          8.1 constructor 構(gòu)造函數(shù)

          先看一下 Creator  類的的構(gòu)造函數(shù):

          module.exports = class Creator extends EventEmitter {
            constructor(name, context, promptModules) {
              super();

              this.name = name;
              this.context = process.env.VUE_CLI_CONTEXT = context;
              // 獲取了 preset 和 feature 的 交互選擇列表,在 vue create 的時候提供選擇
              const { presetPrompt, featurePrompt } = this.resolveIntroPrompts();
              this.presetPrompt = presetPrompt;
              this.featurePrompt = featurePrompt;

              // 交互選擇列表:是否輸出一些文件
              this.outroPrompts = this.resolveOutroPrompts();

              this.injectedPrompts = [];
              this.promptCompleteCbs = [];
              this.afterInvokeCbs = [];
              this.afterAnyInvokeCbs = [];

              this.run = this.run.bind(this);

              const promptAPI = new PromptModuleAPI(this);
              // 將默認的一些配置注入到交互列表中
              promptModules.forEach(m => m(promptAPI));
            }
          };

          構(gòu)造函數(shù)嘛,主要就是初始化一些變量。這里主要將邏輯都封裝在 resolveIntroPrompts / resolveOutroPrompts  和 PromptModuleAPI  這幾個方法中。

          主要看一下 PromptModuleAPI 這個類是干什么的。

          module.exports = class PromptModuleAPI {
            constructor(creator) {
              this.creator = creator;
            }
            // 在 promptModules 里用
            injectFeature(feature) {
              this.creator.featurePrompt.choices.push(feature);
            }
            // 在 promptModules 里用
            injectPrompt(prompt) {
              this.creator.injectedPrompts.push(prompt);
            }
            // 在 promptModules 里用
            injectOptionForPrompt(name, option) {
              this.creator.injectedPrompts
                .find(f => {
                  return f.name === name;
                })
                .choices.push(option);
            }
            // 在 promptModules 里用
            onPromptComplete(cb) {
              this.creator.promptCompleteCbs.push(cb);
            }
          };

          這里我們也簡單說一下,promptModules  返回的是所有用于終端交互的模塊,其中會調(diào)用 injectFeature 和 injectPrompt 來將交互配置插入進去,并且會通過 onPromptComplete  注冊一個回調(diào)。

          onPromptComplete 注冊回調(diào)的形式是往 promptCompleteCbs 這個數(shù)組中 push 了傳入的方法,可以猜測在所有交互完成之后應(yīng)該會通過以下形式來調(diào)用回調(diào):

          this.promptCompleteCbs.forEach(cb => cb(answers, preset));

          回過來看這段代碼:

          module.exports = class Creator extends EventEmitter {
            constructor(name, context, promptModules) {
              const promptAPI = new PromptModuleAPI(this);
              promptModules.forEach(m => m(promptAPI));
            }
          };

          在 Creator  的構(gòu)造函數(shù)中,實例化了一個 promptAPI  對象,并遍歷 prmptModules  把這個對象傳入了 promptModules  中,說明在實例化 Creator  的時候時候就會把所有用于交互的配置注冊好了。

          這里我們注意到,在構(gòu)造函數(shù)中出現(xiàn)了四種 prompt: presetPromptfeaturePrompt, injectedPrompts, outroPrompts,具體有什么區(qū)別呢?下文有有詳細展開。

          8.2 EventEmitter 事件模塊

          首先, Creator  類是繼承于 Node.js 的 EventEmitter 類。眾所周知, events  是 Node.js 中最重要的一個模塊,而 EventEmitter 類就是其基礎(chǔ),是 Node.js 中事件觸發(fā)與事件監(jiān)聽等功能的封裝。

          在這里, Creator  繼承自 EventEmitter , 應(yīng)該就是為了方便在 create  過程中 emit  一些事件,整理了一下,主要就是以下 8 個事件:

          this.emit('creation', { event'creating' }); // 創(chuàng)建
          this.emit('creation', { event'git-init' }); // 初始化 git
          this.emit('creation', { event'plugins-install' }); // 安裝插件
          this.emit('creation', { event'invoking-generators' }); // 調(diào)用 generator
          this.emit('creation', { event'deps-install' }); // 安裝額外的依賴
          this.emit('creation', { event'completion-hooks' }); // 完成之后的回調(diào)
          this.emit('creation', { event'done' }); // create 流程結(jié)束
          this.emit('creation', { event'fetch-remote-preset' }); // 拉取遠程 preset

          我們知道事件 emit  一定會有 on  的地方,是哪呢?搜了一下源碼,是在 @vue/cli-ui 這個包里,也就是說在終端命令行工具的場景下,不會觸發(fā)到這些事件,這里簡單了解一下即可:

          const creator = new Creator('', cwd.get(), getPromptModules());
          onCreationEvent = ({ event }) => {
            progress.set({ id: PROGRESS_ID, status: event, infonull }, context);
          };
          creator.on('creation', onCreationEvent);

          簡單來說,就是通過 vue ui  啟動一個圖形化界面來初始化項目時,會啟動一個 server 端,和終端之間是存在通信的。 server 端掛載了一些事件,在 create 的每個階段,會從 cli 中的方法觸發(fā)這些事件。

          9. Preset(預(yù)設(shè))

          Creator  類的實例方法 create  接受兩個參數(shù):

          • cliOptions:終端命令行傳入的參數(shù)
          • preset:Vue CLI 的預(yù)設(shè)

          9.1 什么是 Preset(預(yù)設(shè))

          Preset 是什么呢?官方解釋是一個包含創(chuàng)建新項目所需預(yù)定義選項和插件的 JSON 對象,讓用戶無需在命令提示中選擇它們。比如:

          {
            "useConfigFiles"true,
            "cssPreprocessor""sass",
            "plugins": {
              "@vue/cli-plugin-babel": {},
              "@vue/cli-plugin-eslint": {
                "config""airbnb",
                "lintOn": ["save""commit"]
              }
            },
            "configs": {
              "vue": {...},
              "postcss": {...},
              "eslintConfig": {...},
              "jest": {...}
            }
          }

          在 CLI 中允許使用本地的 preset 和遠程的 preset。

          9.2 prompt

          用過 inquirer 的朋友的對 prompt 這個單詞一定不陌生,它有 input / checkbox 等類型,是用戶和終端的交互。

          我們回過頭來看一下在 Creator 中的一個方法 getPromptModules, 按照字面意思,這個方法是獲取了一些用于交互的模塊,具體來看一下:

          exports.getPromptModules = () => {
            return [
              'vueVersion',
              'babel',
              'typescript',
              'pwa',
              'router',
              'vuex',
              'cssPreprocessors',
              'linter',
              'unit',
              'e2e',
            ].map(file => require(`../promptModules/${file}`));
          };

          看樣子是獲取了一系列的模塊,返回了一個數(shù)組。我看了一下這里列的幾個模塊,代碼格式基本都是統(tǒng)一的::

          module.exports = cli => {
            cli.injectFeature({
              name'',
              value'',
              short'',
              description'',
              link'',
              checkedtrue,
            });

            cli.injectPrompt({
              name'',
              whenanswers => answers.features.includes(''),
              message'',
              type'list',
              choices: [],
              default'2',
            });

            cli.onPromptComplete((answers, options) => {});
          };

          單獨看 injectFeature 和 injectPrompt 的對象是不是和 inquirer 有那么一點神似?是的,他們就是用戶交互的一些配置選項。那 Feature  和 Prompt  有什么區(qū)別呢?

          Feature:Vue CLI 在選擇自定義配置時的頂層選項:

          Prompt:選擇具體 Feature 對應(yīng)的二級選項,比如選擇了 Choose Vue version 這個 Feature,會要求用戶選擇是 2.x 還是 3.x:

          onPromptComplete 注冊了一個回調(diào)方法,在完成交互之后執(zhí)行。

          看來我們的猜測是對的, getPromptModules 方法就是獲取一些用于和用戶交互的模塊,比如:

          • babel:選擇是否使用 Babel
          • cssPreprocessors:選擇 CSS 的預(yù)處理器(Sass、Less、Stylus)
          • ...

          先說到這里,后面在自定義配置加載的章節(jié)里會展開介紹 Vue CLI 用到的所有 prompt 。

          9.3 獲取預(yù)設(shè)

          我們具體來看一下獲取預(yù)設(shè)相關(guān)的邏輯。這部分代碼在 create  實例方法中:

          // Creator.js
          module.exports = class Creator extends EventEmitter {
            async create(cliOptions = {}, preset = null) {
              const isTestOrDebug = process.env.VUE_CLI_TEST || process.env.VUE_CLI_DEBUG;
              const { run, name, context, afterInvokeCbs, afterAnyInvokeCbs } = this;

              if (!preset) {
                if (cliOptions.preset) {
                  // vue create foo --preset bar
                  preset = await this.resolvePreset(cliOptions.preset, cliOptions.clone);
                } else if (cliOptions.default) {
                  // vue create foo --default
                  preset = defaults.presets.default;
                } else if (cliOptions.inlinePreset) {
                  // vue create foo --inlinePreset {...}
                  try {
                    preset = JSON.parse(cliOptions.inlinePreset);
                  } catch (e) {
                    error(
                      `CLI inline preset is not valid JSON: ${cliOptions.inlinePreset}`
                    );
                    exit(1);
                  }
                } else {
                  preset = await this.promptAndResolvePreset();
                }
              }
            }
          };

          可以看到,代碼中分別針對幾種情況作了處理:

          • cli 參數(shù)配了 --preset
          • cli 參數(shù)配了 --default
          • cli 參數(shù)配了 --inlinePreset
          • cli 沒配相關(guān)參數(shù),默認獲取 Preset 的行為

          前三種情況就不展開說了,我們來看一下第四種情況,也就是默認通過交互 prompt  來獲取 Preset 的邏輯,也就是 promptAndResolvePreset  方法。

          先看一下實際用的時候是什么樣的:

          我們可以猜測這里就是一段 const answers = await inquirer.prompt([])  代碼。

           async promptAndResolvePreset(answers = null) {
              // prompt
              if (!answers) {
                await clearConsole(true);
                answers = await inquirer.prompt(this.resolveFinalPrompts());
              }
              debug("vue-cli:answers")(answers);
           }

           resolveFinalPrompts() {
              this.injectedPrompts.forEach((prompt) => {
                const originalWhen = prompt.when || (() => true);
                prompt.when = (answers) => {
                  return isManualMode(answers) && originalWhen(answers);
                };
              });

              const prompts = [
                this.presetPrompt,
                this.featurePrompt,
                ...this.injectedPrompts,
                ...this.outroPrompts,
              ];
              debug("vue-cli:prompts")(prompts);
              return prompts;
           }

          是的,我們猜的沒錯,將 this.resolveFinalPrompts  里的配置進行交互,而 this.resolveFinalPrompts  方法其實就是將在 Creator  的構(gòu)造函數(shù)里初始化的那些 prompts  合到一起了。上文也提到了有這四種 prompt,在下一節(jié)展開介紹。**

          9.4 保存預(yù)設(shè)

          在 Vue CLI 的最后,會讓用戶選擇 save this as a preset for future?,如果用戶選擇了 Yes,就會執(zhí)行相關(guān)邏輯將這次的交互結(jié)果保存下來。這部分邏輯也是在 promptAndResolvePreset 中。

          async promptAndResolvePreset(answers = null)  {
            if (
              answers.save &&
              answers.saveName &&
              savePreset(answers.saveName, preset)
            ) {
              log();
              log(
                `??  Preset ${chalk.yellow(answers.saveName)} saved in ${chalk.yellow(
                  rcPath
                )}
          `

              );
            }
          }

          在調(diào)用 savePreset 之前還會對預(yù)設(shè)進行解析、校驗等,就不展開了,直接來看一下 savePreset 方法:

          exports.saveOptions = toSave => {
            const options = Object.assign(cloneDeep(exports.loadOptions()), toSave);
            for (const key in options) {
              if (!(key in exports.defaults)) {
                delete options[key];
              }
            }
            cachedOptions = options;
            try {
              fs.writeFileSync(rcPath, JSON.stringify(options, null2));
              return true;
            } catch (e) {
              error(
                `Error saving preferences: ` +
                  `make sure you have write access to ${rcPath}.\n` +
                  `(${e.message})`
              );
            }
          };

          exports.savePreset = (name, preset) => {
            const presets = cloneDeep(exports.loadOptions().presets || {});
            presets[name] = preset;
            return exports.saveOptions({ presets });
          };

          代碼很簡單,先深拷貝一份 Preset(這里直接用的 lodash 的 clonedeep),然后進過一些 merge 的操作之后就 writeFileSync 到上文有提到的 .vuerc 文件了。

          10. 自定義配置加載

          這四種 prompt  分別對應(yīng)的是預(yù)設(shè)選項、自定義 feature 選擇、具體 feature 選項和其它選項,它們之間存在互相關(guān)聯(lián)、層層遞進的關(guān)系。結(jié)合這四種 prompt,就是 Vue CLI 展現(xiàn)開用戶面前的所有交互了,其中也包含自定義配置的加載。

          10.1 presetPrompt: 預(yù)設(shè)選項

          也就是最初截圖里看到的哪三個選項,選擇 Vue2 還是 Vue3 還是自定義 feature

          如果選擇了 Vue2  或者 Vue3 ,則后續(xù)關(guān)于 preset  所有的 prompt  都會終止。

          10.2 featurePrompt: 自定義 feature 選項

          ** 如果在 presetPrompt  中選擇了 Manually,則會繼續(xù)選擇 feature

          featurePrompt  就是存儲的這個列表,對應(yīng)的代碼是這樣的:

          const isManualMode = answers => answers.preset === '__manual__';

          const featurePrompt = {
            name'features',
            when: isManualMode,
            type'checkbox',
            message'Check the features needed for your project:',
            choices: [],
            pageSize10,
          };

          在代碼中可以看到,在 isManualMode  的時候才會彈出這個交互。

          10.3 injectedPrompts: 具體 feature 選項

          featurePrompt  只是提供了一個一級列表,當用戶選擇了 Vue Version / Babel / TypeScript  等選項之后,會彈出新的交互,比如 Choose Vue version

          injectedPrompts  就是存儲的這些具體選項的列表,也就是上文有提到通過 getPromptModules 方法在 promptModules  目錄獲取到的那些 prompt  模塊:

          對應(yīng)的代碼可以再回顧一下:

          cli.injectPrompt({
            name'vueVersion',
            whenanswers => answers.features.includes('vueVersion'),
            message'Choose a version of Vue.js that you want to start the project with',
            type'list',
            choices: [
              {
                name'2.x',
                value'2',
              },
              {
                name'3.x (Preview)',
                value'3',
              },
            ],
            default'2',
          });

          可以看到,在 answers => answers.features.includes('vueVersion'),也就是 featurePrompt 的交互結(jié)果中如果包含 vueVersion  就會彈出具體選擇 Vue Version  的交互。

          10.4 outroPrompts: 其它選項

          ** 這里存儲的就是一些除了上述三類選項之外的選項目前包含三個:

          **Where do you prefer placing config for Babel, ESLint, etc.? **Babel,ESLint 等配置文件如何存儲?

          • In dedicated config files。單獨保存在各自的配置文件中。
          • In package.json。統(tǒng)一存儲在 package.json 中。

          **Save this as a preset for future projects? **是否保存這次 Preset 以便之后直接使用。

          如果你選擇了 Yes,則會再出來一個交互:Save preset as 輸入 Preset 的名稱。

          10.5 總結(jié):Vue CLI 交互流程

          這里總結(jié)一下 Vue CLI 的整體交互,也就是 prompt  的實現(xiàn)。

          也就是文章最開始的時候提到,Vue CLI 支持默認配置之外,也支持自定義配置(Babel、TS 等),這樣一個交互流程是如何實現(xiàn)的。

          Vue CLI 將所有交互分為四大類:

          從預(yù)設(shè)選項到具體 feature 選項,它們是一個層層遞進的關(guān)系,不同的時機和選擇會觸發(fā)不同的交互。

          Vue CLI 這里在代碼架構(gòu)上的設(shè)計值得學習,將各個交互維護在不同的模塊中,通過統(tǒng)一的一個 prmoptAPI  實例在 Creator  實例初始化的時候,插入到不同的 prompt  中,并且注冊各自的回調(diào)函數(shù)。這樣設(shè)計對于 prompt  而言是完全解耦的,刪除某一項 prompt  對于上下文的影響可以忽略不計。

          好了,關(guān)于預(yù)設(shè)(Preset)和交互(Prompt)到這里基本分析完了,剩下的一些細節(jié)問題就不再展開了。

          這里涉及到的相關(guān)源碼文件有,大家可以自行看一下:

          • Creator.js
          • PromptModuleAPI.js
          • utils/createTools.js
          • promptModules
          • ...

          11. 初始化項目基礎(chǔ)文件

          當用戶選完所有交互之后,CLI 的下一步職責就是根據(jù)用戶的選項去生成對應(yīng)的代碼了,這也是 CLI 的核心功能之一。

          11.1 初始化 package.json 文件

          根據(jù)用戶的選項會掛載相關(guān)的 vue-cli-plugin,然后用于生成 package.json  的依賴 devDependencies,比如 @vue/cli-service / @vue/cli-plugin-babel / @vue/cli-plugin-eslint  等。

          Vue CLI 會現(xiàn)在創(chuàng)建目錄下寫入一個基礎(chǔ)的 package.json :

          {
            "name""a",
            "version""0.1.0",
            "private"true,
            "devDependencies": {
              "@vue/cli-plugin-babel""~4.5.0",
              "@vue/cli-plugin-eslint""~4.5.0",
              "@vue/cli-service""~4.5.0"
            }
          }

          11.2 初始化 Git

          根據(jù)傳入的參數(shù)和一系列的判斷,會在目標目錄下初始化 Git 環(huán)境,簡單來說就是執(zhí)行一下 git init

          await run('git init');

          具體是否初始化 Git 環(huán)境是這樣判斷的:

          shouldInitGit(cliOptions) {
            // 如果全局沒安裝 Git,則不初始化
            if (!hasGit()) {
              return false;
            }
            // 如果 CLI 有傳入 --git 參數(shù),則初始化
            if (cliOptions.forceGit) {
              return true;
            }
            // 如果 CLI 有傳入 --no-git,則不初始化
            if (cliOptions.git === false || cliOptions.git === "false") {
              return false;
            }
            // 如果當前目錄下已經(jīng)有 Git 環(huán)境,就不初始化
            return !hasProjectGit(this.context);
          }

          11.3 初始化 README.md

          項目的 README.md  會根據(jù)上下文動態(tài)生成,而不是寫死的一個文檔:

          function generateReadme(pkg, packageManager{
            return [
              `# ${pkg.name}\n`,
              '## Project setup',
              '```',
              `${packageManager} install`,
              '```',
              printScripts(pkg, packageManager),
              '### Customize configuration',
              'See [Configuration Reference](https://cli.vuejs.org/config/).',
              '',
            ].join('\n');
          }

          Vue CLI 創(chuàng)建的 README.md  會告知用戶如何使用這個項目,除了 npm install  之外,會根據(jù) package.json  里的 scripts  參數(shù)來動態(tài)生成使用文檔,比如如何開發(fā)、構(gòu)建和測試:

          const descriptions = {
            build'Compiles and minifies for production',
            serve'Compiles and hot-reloads for development',
            lint'Lints and fixes files',
            'test:e2e''Run your end-to-end tests',
            'test:unit''Run your unit tests',
          };

          function printScripts(pkg, packageManager{
            return Object.keys(pkg.scripts || {})
              .map(key => {
                if (!descriptions[key]) return '';
                return [
                  `\n### ${descriptions[key]}`,
                  '```',
                  `${packageManager} ${packageManager !== 'yarn' ? 'run ' : ''}${key}`,
                  '```',
                  '',
                ].join('\n');
              })
              .join('');
          }

          這里可能會有讀者問,為什么不直接拷貝一個 README.md  文件過去呢?

          • 第一,Vue CLI 支持不同的包管理,對應(yīng)安裝、啟動和構(gòu)建腳本都是不一樣的,這個是需要動態(tài)生成的;
          • 第二,動態(tài)生成自由性更強,可以根據(jù)用戶的選項去生成對應(yīng)的文檔,而不是大家都一樣。

          11.4 安裝依賴

          調(diào)用 ProjectManage 的 install 方法安裝依賴,代碼不復(fù)雜:

           async install () {
             if (this.needsNpmInstallFix) {
               // 讀取 package.json
               const pkg = resolvePkg(this.context)
               // 安裝 dependencies
               if (pkg.dependencies) {
                 const deps = Object.entries(pkg.dependencies).map(([dep, range]) => `${dep}@${range}`)
                 await this.runCommand('install', deps)
               }
               // 安裝 devDependencies
               if (pkg.devDependencies) {
                 const devDeps = Object.entries(pkg.devDependencies).map(([dep, range]) => `${dep}@${range}`)
                 await this.runCommand('install', [...devDeps, '--save-dev'])
               }
               // 安裝 optionalDependencies
               if (pkg.optionalDependencies) {
                 const devDeps = Object.entries(pkg.devDependencies).map(([dep, range]) => `${dep}@${range}`)
                 await this.runCommand('install', [...devDeps, '--save-optional'])
               }
               return
             }
             return await this.runCommand('install'this.needsPeerDepsFix ? ['--legacy-peer-deps'] : [])
           }

          簡單來說就是讀取 package.json 然后分別安裝 npm 的不同依賴。

          這里的邏輯深入進去感覺還是挺復(fù)雜的,我也沒仔細深入看,就不展開說了。。。

          11.4.1 自動判斷 NPM 源

          這里有一個有意思的點,關(guān)于安裝依賴時使用的 npm 倉庫源。如果用戶沒有指定安裝源,Vue CLI 會自動判斷是否使用淘寶的 NPM 安裝源,猜猜是如何實現(xiàn)的?

          function shouldUseTaobao({
            let faster
            try {
              faster = await Promise.race([
                ping(defaultRegistry),
                ping(registries.taobao)
              ])
            } catch (e) {
              return save(false)
            }

            if (faster !== registries.taobao) {
              // default is already faster
              return save(false)
            }

            const { useTaobaoRegistry } = await inquirer.prompt([
              {
                name'useTaobaoRegistry',
                type'confirm',
                message: chalk.yellow(
                  ` Your connection to the default ${command} registry seems to be slow.\n` +
                    `   Use ${chalk.cyan(registries.taobao)} for faster installation?`
                )
              }
            ])
            return save(useTaobaoRegistry);
          }

          Vue CLI 中會通過 Promise.race 去請求默認安裝源淘寶安裝源: **

          • 如果先返回的是淘寶安裝源,就會讓用戶確認一次,是否使用淘寶安裝源
          • 如果先返回的是默認安裝源,就會直接使用默認安裝源

          一般來說,肯定都是使用默認安裝源,但是考慮國內(nèi)用戶。??瓤取!檫@個設(shè)計點贊。

          15. Generator 生成代碼

          除了 Creator  外,整個 Vue CLI 的第二大重要的類是 Generator,負責項目代碼的生成,來具體看看干了啥。

          15.1 初始化插件

          在 generate  方法中,最先執(zhí)行的是一個 initPlugins  方法,代碼如下:

          async initPlugins () {
            for (const id of this.allPluginIds) {
              const api = new GeneratorAPI(id, this, {}, rootOptions)
              const pluginGenerator = loadModule(`${id}/generator`this.context)

              if (pluginGenerator && pluginGenerator.hooks) {
                await pluginGenerator.hooks(api, {}, rootOptions, pluginIds)
              }
            }
          }

          在這里會給每一個 package.json  里的插件初始化一個 GeneratorAPI  實例,將實例傳入對應(yīng)插件的 generator  方法并執(zhí)行,比如 @vue/cli-plugin-babel/generator.js

          15.2 GeneratorAPI 類

          Vue CLI 使用了一套基于插件的架構(gòu)。如果你查閱一個新創(chuàng)建項目的 package.json,就會發(fā)現(xiàn)依賴都是以 @vue/cli-plugin- 開頭的。插件可以修改 webpack 的內(nèi)部配置,也可以向 vue-cli-service 注入命令。在項目創(chuàng)建的過程中,絕大部分列出的特性都是通過插件來實現(xiàn)的。

          剛剛提到,會往每一個插件的 generator  中傳入 GeneratorAPI  的實例,看看這個類提供了什么。

          15.2.1 例子:@vue/cli-plugin-babel

          為了不那么抽象,我們先拿 @vue/cli-plugin-babel 來看,這個插件比較簡單:

          module.exports = api => {
            delete api.generator.files['babel.config.js'];

            api.extendPackage({
              babel: {
                presets: ['@vue/cli-plugin-babel/preset'],
              },
              dependencies: {
                'core-js''^3.6.5',
              },
            });
          };

          這里 api  就是一個 GeneratorAPI 實例,這里用到了一個 extendPackage  方法:

          // GeneratorAPI.js
          // 刪減部分代碼,只針對 @vue/cli-plugin-babel 分析
          extendPackage (fields, options = {}) {
            const pkg = this.generator.pkg
            const toMerge = isFunction(fields) ? fields(pkg) : fields
            // 遍歷傳入的參數(shù),這里是 babel 和 dependencies 兩個對象
            for (const key in toMerge) {
              const value = toMerge[key]
              const existing = pkg[key]
              // 如果 key 的名稱是 dependencies 和 devDependencies
              // 就通過 mergeDeps 方法往 package.json 合并依賴
              if (isObject(value) && (key === 'dependencies' || key === 'devDependencies')) {
                pkg[key] = mergeDeps(
                  this.id,
                  existing || {},
                  value,
                  this.generator.depSources,
                  extendOptions
                )
              } else if (!extendOptions.merge || !(key in pkg)) {
                pkg[key] = value
              }
            }
          }

          這時候,默認的 package.json  就變成:

          {
            "babel": {
              "presets": ["@vue/cli-plugin-babel/preset"]
            },
            "dependencies": {
              "core-js""^3.6.5"
            },
            "devDependencies": {},
            "name""test",
            "private"true,
            "version""0.1.0"
          }

          看完這個例子,對于 GeneratorAPI  的實例做什么可能有些了解了,我們就來具體看看這個類的實例吧。

          15.2.2 重要的幾個實例方法

          先介紹幾個 GeneratorAPI  重要的實例方法,這里就只介紹功能,具體代碼就不看了,等等會用到。

          • extendPackage:拓展 package.json 配置
          • render:通過 ejs 渲染模板文件
          • onCreateComplete: 注冊文件寫入硬盤之后的回調(diào)
          • genJSConfig: 將 json 文件輸出成 js 文件
          • injectImports: 向文件中加入 import
          • ...

          16. @vue/cli-service

          上文已經(jīng)看過一個 @vue/cli-plugin-babel  插件,對于 Vue CLI 的插件架構(gòu)是不是有點感覺?也了解到一個比較重要的 GeneratorAPI  類,插件中的一些修改配置的功能都是這個類的實例方法。

          接下來看一個比較重要的插件 @vue/cli-service,這個插件是 Vue CLI 的核心插件,和 create react app  的 react-scripts  類似,借助這個插件,我們應(yīng)該能夠更深刻地理解 GeneratorAPI 以及 Vue CLI 的插件架構(gòu)是如何實現(xiàn)的。

          來看一下 @vue/cli-service  這個包下的 generator/index.js  文件,這里為了分析方便,將源碼拆解成多段,其實也就是分別調(diào)用了 GeneratorAPI  實例的不同方法:

          16.1 渲染 template

          api.render('./template', {
            doesCompile: api.hasPlugin('babel') || api.hasPlugin('typescript'),
          });

          將 template  目錄下的文件通過 render  渲染到內(nèi)存中,這里用的是 ejs  作為模板渲染引擎。

          16.2 寫 package.json

          通過 extendPackage 往 pacakge.json 中寫入 Vue   的相關(guān)依賴:

          if (options.vueVersion === '3') {
            api.extendPackage({
              dependencies: {
                vue'^3.0.0',
              },
              devDependencies: {
                '@vue/compiler-sfc''^3.0.0',
              },
            });
          else {
            api.extendPackage({
              dependencies: {
                vue'^2.6.11',
              },
              devDependencies: {
                'vue-template-compiler''^2.6.11',
              },
            });
          }

          通過 extendPackage 往 pacakge.json 中寫入 scripts

          api.extendPackage({
            scripts: {
              serve'vue-cli-service serve',
              build'vue-cli-service build',
            },
            browserslist: ['> 1%''last 2 versions''not dead'],
          });

          通過 extendPackage 往 pacakge.json 中寫入 CSS 預(yù)處理參數(shù):

          if (options.cssPreprocessor) {
            const deps = {
              sass: {
                sass'^1.26.5',
                'sass-loader''^8.0.2',
              },
              'node-sass': {
                'node-sass''^4.12.0',
                'sass-loader''^8.0.2',
              },
              'dart-sass': {
                sass'^1.26.5',
                'sass-loader''^8.0.2',
              },
              less: {
                less'^3.0.4',
                'less-loader''^5.0.0',
              },
              stylus: {
                stylus'^0.54.7',
                'stylus-loader''^3.0.2',
              },
            };

            api.extendPackage({
              devDependencies: deps[options.cssPreprocessor],
            });
          }

          16.3 調(diào)用 router 插件和 vuex 插件

          // for v3 compatibility
          if (options.router && !api.hasPlugin('router')) {
            require('./router')(api, options, options);
          }

          // for v3 compatibility
          if (options.vuex && !api.hasPlugin('vuex')) {
            require('./vuex')(api, options, options);
          }

          是不是很簡單,通過 GeneratorAPI  提供的實例方法,可以在插件中非常方便地對項目進行修改和自定義。

          17. 抽取單獨配置文件

          上文提到,通過 extendPackage  回往 package.json  中寫入一些配置。但是,上文也提到有一個交互是 Where do you prefer placing config for Babel, ESLint, etc.? 也就是會將配置抽取成單獨的文件。generate  里的 extractConfigFiles  方法就是執(zhí)行了這個邏輯。

          extractConfigFiles(extractAll, checkExisting) {
            const configTransforms = Object.assign(
              {},
              defaultConfigTransforms,
              this.configTransforms,
              reservedConfigTransforms
            );
            const extract = (key) => {
              if (
                configTransforms[key] &&
                this.pkg[key] &&
                !this.originalPkg[key]
              ) {
                const value = this.pkg[key];
                const configTransform = configTransforms[key];
                const res = configTransform.transform(
                  value,
                  checkExisting,
                  this.files,
                  this.context
                );
                const { content, filename } = res;
                this.files[filename] = ensureEOL(content);
                delete this.pkg[key];
              }
            };
            if (extractAll) {
              for (const key in this.pkg) {
                extract(key);
              }
            } else {
              extract("babel");
            }
          }

          這里的 configTransforms  就是一些會需要抽取的配置:

          如果 extractAll  是 true,也就是在上面的交互中選了 Yes,就會將 package.json  里的所有 key configTransforms 比較,如果都存在,就將配置抽取到獨立的文件中。

          18. 將內(nèi)存中的文件輸出到硬盤

          上文有提到,api.render  會通過 EJS 將模板文件渲染成字符串放在內(nèi)存中。執(zhí)行了 generate  的所有邏輯之后,內(nèi)存中已經(jīng)有了需要輸出的各種文件,放在 this.files  里。 generate  的最后一步就是調(diào)用 writeFileTree  將內(nèi)存中的所有文件寫入到硬盤。

          到這里 generate  的邏輯就基本都講完了,Vue CLI 生成代碼的部分也就講完了。

          19. 總結(jié)

          整體看下來,Vue CLI 的代碼還是比較復(fù)雜的,整體架構(gòu)條理還是比較清楚的,其中有兩點印象最深:

          第一,整體的交互流程的掛載。將各個模塊的交互邏輯通過一個類的實例維護起來,執(zhí)行時機和成功回調(diào)等也是設(shè)計的比較好。

          第二,插件機制很重要。插件機制將功能和腳手架進行解耦。

          看來,無論是 create-react-app 還是 Vue CLI,在設(shè)計的時候都會盡量考慮插件機制,將能力開放出去再將功能集成進來,無論是對于 Vue CLI 本身的核心功能,還是對于社區(qū)開發(fā)者來說,都具備了足夠的開放性和擴展性。

          整體代碼看下來,最重要的就是兩個概念:

          • Preset:預(yù)設(shè),包括整體的交互流程(Prompt)
          • Plugin:插件,整體的插件系統(tǒng)

          圍繞這兩個概念,代碼中的這幾個類:Creator、PromptModuleAPI、GeneratorGeneratorAPI 就是核心。

          簡單總結(jié)一下流程:

          1. 執(zhí)行 vue create
          2. 初始化 Creator 實例 creator,掛載所有交互配置
          3. 調(diào)用 creator 的實例方法 create
          4. 詢問用戶自定義配置
          5. 初始化 Generator 實例 generator
          6. 初始化各種插件
          7. 執(zhí)行插件的 generator 邏輯,寫 package.json、渲染模板等
          8. 將文件寫入到硬盤

          這樣一個 CLI 的生命周期就走完了,項目已經(jīng)初始化好了。

          附:Vue CLI 中可以直接拿來用的工具方法

          看完 Vue CLI 的源碼,除了感嘆這復(fù)雜的設(shè)計之外,也發(fā)現(xiàn)很多工具方法,在我們實現(xiàn)自己的 CLI 時,都是可以拿來即用的,在這里總結(jié)一下。

          獲取 CLI 參數(shù)

          解析 CLI 通過 -- 傳入的參數(shù)。

          const program = require('commander');

          function camelize(str{
            return str.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : ''));
          }

          function cleanArgs(cmd{
            const args = {};
            cmd.options.forEach(o => {
              const key = camelize(o.long.replace(/^--/''));
              // if an option is not present and Command has a method with the same name
              // it should not be copied
              if (typeof cmd[key] !== 'function' && typeof cmd[key] !== 'undefined') {
                args[key] = cmd[key];
              }
            });
            return args;
          }

          檢查 Node 版本

          通過 semver.satisfies 比較兩個 Node 版本:

          • process.version: 當前運行環(huán)境的 Node 版本
          • wanted: package.json 里配置的 Node 版本
          const requiredVersion = require('../package.json').engines.node;

          function checkNodeVersion(wanted, id{
            if (!semver.satisfies(process.version, wanted, { includePrereleasetrue })) {
              console.log(
                chalk.red(
                  'You are using Node ' +
                    process.version +
                    ', but this version of ' +
                    id +
                    ' requires Node ' +
                    wanted +
                    '.\nPlease upgrade your Node version.'
                )
              );
              process.exit(1);
            }
          }

          checkNodeVersion(requiredVersion, '@vue/cli');

          讀取 package.json

          const fs = require('fs');
          const path = require('path');

          function getPackageJson(cwd{
            const packagePath = path.join(cwd, 'package.json');

            let packageJson;
            try {
              packageJson = fs.readFileSync(packagePath, 'utf-8');
            } catch (err) {
              throw new Error(`The package.json file at '${packagePath}' does not exist`);
            }

            try {
              packageJson = JSON.parse(packageJson);
            } catch (err) {
              throw new Error('The package.json is malformed');
            }

            return packageJson;
          }

          對象排序

          這里主要是在輸出 package.json 的時候可以對輸出的對象先進行排序,更美觀一些。。

          module.exports = function sortObject(obj, keyOrder, dontSortByUnicode{
            if (!obj) return;
            const res = {};

            if (keyOrder) {
              keyOrder.forEach(key => {
                if (obj.hasOwnProperty(key)) {
                  res[key] = obj[key];
                  delete obj[key];
                }
              });
            }

            const keys = Object.keys(obj);

            !dontSortByUnicode && keys.sort();
            keys.forEach(key => {
              res[key] = obj[key];
            });

            return res;
          };

          輸出文件到硬盤

          這個其實沒啥,就是三步:

          • fs.unlink 刪除文件
          • fs.ensureDirSync 創(chuàng)建目錄
          • fs.writeFileSync 寫文件
          const fs = require('fs-extra');
          const path = require('path');

          // 刪除已經(jīng)存在的文件
          function deleteRemovedFiles(directory, newFiles, previousFiles{
            // get all files that are not in the new filesystem and are still existing
            const filesToDelete = Object.keys(previousFiles).filter(
              filename => !newFiles[filename]
            );

            // delete each of these files
            return Promise.all(
              filesToDelete.map(filename => {
                return fs.unlink(path.join(directory, filename));
              })
            );
          }

          // 輸出文件到硬盤
          module.exports = async function writeFileTree(dir, files, previousFiles{
            if (previousFiles) {
              await deleteRemovedFiles(dir, files, previousFiles);
            }
            // 主要就是這里
            Object.keys(files).forEach(name => {
              const filePath = path.join(dir, name);
              fs.ensureDirSync(path.dirname(filePath));
              fs.writeFileSync(filePath, files[name]);
            });
          };

          判斷項目是否初始化 git

          其實就是在目錄下執(zhí)行 git status 看是否報錯。

          const hasProjectGit = cwd => {
            let result;
            try {
              execSync('git status', { stdio'ignore', cwd });
              result = true;
            } catch (e) {
              result = false;
            }
            return result;
          };

          對象的 get 方法

          可以用 lodash,現(xiàn)在可以直接用 a?.b?.c 就好了

          function get(target, path{
            const fields = path.split('.');
            let obj = target;
            const l = fields.length;
            for (let i = 0; i < l - 1; i++) {
              const key = fields[i];
              if (!obj[key]) {
                return undefined;
              }
              obj = obj[key];
            }
            return obj[fields[l - 1]];
          }
          瀏覽 41
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  日P网站 日本尻屄 | 91精品国产91久久久久久 | 人体毛片| 97福利 | 水多多www视频在线观看高清 |