Skip to content

Latest commit

 

History

History
522 lines (417 loc) · 18.4 KB

20210611.md

File metadata and controls

522 lines (417 loc) · 18.4 KB

2021-06-11 技术周刊

精读

Vite 模块加载及源码解析

1% * 天才

一切始于一个想法。。。

image:https://raw.githubusercontent.com/tuateam/weekly/master/assets/20210611/1.jpg

然后,过了几天, image:9E8DE760-D0CD-449F-AB71-60DF8573BEC5-533-0000D1AF7FB435F7/AFCFA181-E36B-4381-99C9-462A75EBE0C0.png

这个想法来源于这篇文章,A Future Without Webpack 好的想法,也需要天时地利人和。

浏览器 和 Bundles 和 Unbundled

此图来自 How Snowpack Works image:B22E3D99-B137-40D3-AB75-C6A476663CB4-533-0000D1CDC6767D8A/sdfxrngqnlji4idz.jpg

Bundled

Html中加载打包后的文件

  • 启动: 打包完整的应用代码后启动

  • 热更新 有更新的模块,以及依赖这个模块的模块,重新编译,打包,整体推动更新

  • 模块管理 commonjs

unbundled

通过esm加载js文件

  • 启动 直接启动,用到才更新,然后,可以偷偷的在后台预热

  • 热更新 有更新的模块,以及依赖这个模块的模块,重新编译,逐个推动更新

  • 模块管理 ESM

ESM

思考一下,变量如果没有作用域,代码怎么写?你能坚持写多少行? image:32FEBE5C-A32A-42AF-B694-85F15040FB89-533-0000D5CD8E5C4B03/162cec75c1b252af.webp

有了作用域,你就可以只关心变量在它自身上下文之间的关系。 在作用域外去共享你的变量怎么办呢?你需要放到外,还要保证顺序,你要在用它之前定义好它。像这样,

<script src="/tua.js"></script>
<script>
	tua.start();
	console.log('hello, tua!')
</script>

如果共享的变量相互依赖太多,你能维护多少个变量的顺序而不混乱?和没有作用域难度基本等同。

后来出现了模块化Modules/1.1.1 - CommonJS Spec Wiki。它给了我们一个方式去组织这些变量和函数。通过模块化,你可以把变量和函数合理的进行分组归类。再后来,出现了esm规范 ECMAScript® 2022 Language Specification,也就是我们现在熟知的import, export。

image:52600798-01CE-459D-8D52-AAD527426B1B-533-0000D64D2FE4B690/v2-3aa9e497ffaebbff640f51c798b17140_1440w.jpg

那ESM是如何工作的呢?

浏览器或node 遇到 import,可以知道加载哪些代码。你只需要提供一个入口文件,通过解析,就能循着import语句,顺藤摸瓜找到所有的代码。 image:DDB59072-A8B9-44EE-B0A3-55C91AEF3C7E-533-0000D73F0D2FA591/162cec75c3595b82.webp

ESM文件不能直接被浏览器所用,需要解析,创建模块实例。模块的加载是通过入口人间,找到整个模块实例的关系表。过程如下:

构建(查找下载解析)实例化 (链接exportsimport) 求值 (运行,变量赋值) image:A76C2D7A-CE19-4767-B193-54308999FAA3-533-0000D785D0C37EA4/162cec760f871dc3.webp

其中第二步的实例化, 首先在内存中指定位置给各个模块的export导出的变量或者函数,接着将模块中对应的import部分同样指向对应的export的内存地址。

入口要怎么加载,通过script标签告知浏览器:

<script src="main.js" type="module"></script>

image:96CBEDD7-7CBC-42FF-93E6-8D7513E222EF-533-0000D845E4F2C103/162cec763a65defd.webp

注意,上面的解析过程中,浏览器主动发起请求import的文件。

commonjs 和 esm 的区别

  1. ES modules 是静态依赖,在执行模块解析和求值操作之前,就建立好了整个模块依赖关系表。
  2. ES modules 将模块声明算法拆分到各个阶段去执行。不阻塞主线程。
  3. ES modules 动态绑定

image:1E60CAA3-4DF9-44BE-93BD-ACA86A47DF93-533-0000D8DA60460F21/162cec7670cb29c4.webp

参考:【翻译】ES modules:通过漫画进行深入理解

Vite 的加载解析机制

它是 Vite 的核心模块。

要有服务器

首先看下面这段代码,思考一下,这段代码在浏览器里执行会怎样?

import { createApp } from 'vue'
import App from './App.vue'
import './index.css'

createApp(App).mount('#app')

代码要怎样才能正常的运行?

如果是这样的代码呢,是不是简单了些:

import { createApp } from '/node_modules/vue'
import App from '/src/App.vue'
import '/src/index.css'

createApp(App).mount('#app')

如果这些文件和HTML都在同一域下,我们就可以使用esm愉快的打开这个页面了,等等。。。 .vue 怎么办?

如果 App.vue 是:

import HelloWorld from '/src/components/HelloWorld.vue'

const __script = {
  name: 'App',
  components: {
    HelloWorld
  }
}

import { render as __render } from "/src/App.vue?type=template"
__script.render = __render
__script.__hmrId = "/src/App.vue"
__script.__file = "/Users/knight/workspace/vite_test/vite_test/src/App.vue"
export default __script

image:8677F203-F94F-4F6D-AC0E-B59D0723860D-533-0000DABEB52D8E1A/D9C2F807-AC43-4601-A04D-11C9BCFBE653.png

不要被文件扩展名欺骗,同样还有css:

import { updateStyle } from "/vite/client"
const css = "#app {\n  font-family: Avenir, Helvetica, Arial, sans-serif;\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n  text-align: center;\n  color: #2c3e50;\n  margin-top: 60px;\n}\n"
updateStyle("\"2418ba23\"", css)
export default css

image:8B403F29-A41C-4964-B43D-8566BC412F0A-533-0000DACE94897CC6/70E372A9-9373-4C10-BC25-A0B644C7B34C.png

一切都是esm模块。 看来,我们需要一个服务器,能拦截这些请求,处理源码,返回 content-type:application/javascript; 的 ESM 模块的服务器。koa就是Vite所选的承担这一重任的服务端。

// src/server/index.ts 1.0.0 rc15
  const app = new Koa<State, Context>()
  const server = resolveServer(config, app.callback())
  const watcher = chokidar.watch(root, {
    ignored: [/node_modules/, /\.git/],
    // #610
    awaitWriteFinish: {
      stabilityThreshold: 100,
      pollInterval: 10
    }
  }) as HMRWatcher
  const resolver = createResolver(root, resolvers, alias)

这里启动了一个koa服务器,处理所有的文件和请求,利用中间件作为插件来。那都用了那些中间件呢?

// 有删减
 const resolvedPlugins = [
    sourceMapPlugin,
    moduleRewritePlugin,
    htmlRewritePlugin,
    // user plugins
    vuePlugin,
    cssPlugin,
    enableEsbuild ? esbuildPlugin : null,
    jsonPlugin,
    assetPathPlugin,
    webWorkerPlugin,
    wasmPlugin,
    serveStaticPlugin
  ]

 resolvedPlugins.forEach((m) => m && m(context))

// 插件格式
import { ServerPlugin } from '.'
export const vitexxPlugin: ServerPlugin = ({ app }) => {
  app.use(async (ctx, next) => {
    // 插件逻辑
    return next()
  })
}

这里主要看下 moduleRewritePlugin,负责模块的重写

moduleRewritePlugin 模块路径重写

主流程

  • 将命名的模块导入重写为/@modules/:id请求,例如。“vue” => “/@modules/vue”
  • 重写包含HMR代码的文件(参考import.meta.hot)到* 注入import.meta.hot并跟踪HMR边界接受白名单。*
  • 在重写过程中还跟踪 importer/importee 关系。* 该图被HMR插件用来对文件变化进行分析。*
app.use(async (ctx, next) => {
	  await next()
    if (ctx.status === 304) {
      return
    }
    // 我们在所有其他中间件完成后再进行js重写。
    // 这使我们能够对用户中间件产生的javascript进行后处理。
    // 无论原始文件的扩展名是什么。
    const publicPath = ctx.path
    if (
      ctx.body &&
      ctx.response.is('js') &&
      !isCSSRequest(ctx.path) &&
      !ctx.url.endsWith('.map') &&
      !resolver.isPublicRequest(ctx.path) &&
      // skip internal client
      // 跳过内部客户端, 过滤掉不处理
      publicPath !== clientPublicPath &&
      //需要重写vue文件中的<script>/<template>部分
      !((ctx.path.endsWith('.vue') || ctx.vue) && ctx.query.type === 'style')
    ) {
      const content = await readBody(ctx.body)
      // 用路径和字符串内容当key
      const cacheKey = publicPath + content
      // ? t 
      const isHmrRequest = !!ctx.query.t
      if (!isHmrRequest && rewriteCache.has(cacheKey)) {
        debug(`(cached) ${ctx.url}`)
        // 返回缓存中的内容
        ctx.body = rewriteCache.get(cacheKey)
      } else {
        await initLexer
        // 动态导入可能包含无扩展的路径。
        // (.e.g import(runtimePathString))
        // 所以我们需要在我们进行 hmr 分析之前,对importer进行规范化处理,确保它包含扩展名。
        // 另一方面,静态导入可以保证包含扩展名。
        // 因为它们肯定都经过了模块重写。
        const importer = removeUnRelatedHmrQuery(
          resolver.normalizePublicPath(ctx.url)
        )
        ctx.body = rewriteImports(
          root,
          content!,
          importer,
          resolver,
          ctx.query.t
        )
        if (!isHmrRequest) {
          rewriteCache.set(cacheKey, ctx.body)
        }
      }
    } else {
      debug(`(skipped) ${ctx.url}`)
    }
  })

这里的debug信息可以看出路径怎么转换的:

 vite:rewrite     "vue" --> "/@modules/vue?import" +3ms
 vite:rewrite     "./App.vue" --> "/src/App.vue" +2ms
 vite:rewrite     "./index.css" --> "/src/index.css?import" +0ms
  1. 有缓存,返回缓存中内容。
 // 用路径和字符串内容当key
      const cacheKey = publicPath + content
      // ? t 
      const isHmrRequest = !!ctx.query.t
      if (!isHmrRequest && rewriteCache.has(cacheKey)) {
        debug(`(cached) ${ctx.url}`)
        // 返回缓存中的内容
        ctx.body = rewriteCache.get(cacheKey)
      }
  1. 分析依赖,获取内容,缓存之 动态导入可能包含无扩展的路径。比如import(runtimePathString), 所以我们需要在我们进行 hmr 分析之前,对importer进行规范化处理,确保它包含扩展名。另一方面,静态导入可以保证包含扩展名。因为它们肯定都经过了模块重写。
await initLexer
const importer = removeUnRelatedHmrQuery(
   resolver.normalizePublicPath(ctx.url)
)
ctx.body = rewriteImports(
   root,
   content!,
   importer,
   resolver,
   ctx.query.t
)
if (!isHmrRequest) {
   rewriteCache.set(cacheKey, ctx.body)
}

在 rewriteImports 方法中使用 es-module-lexer 来进行词法分析。并且将最终已经 replace 模块路径的结果赋值给 ctx.body

rewriteImports

if (imports.length || hasHMR || hasEnv) {
      const s = new MagicString(source)
      let hasReplaced = false

      // 这里找引用此模块的模块列表, 找出它影响到的模块
      const prevImportees = importeeMap.get(importer)
      const currentImportees = new Set<string>()
      importeeMap.set(importer, currentImportees) // 更新影响到的模块

      for (let i = 0; i < imports.length; i++) { // 遍历 import
        const { s: start, e: end, d: dynamicIndex } = imports[i]
        let id = source.substring(start, end)
        let hasLiteralDynamicId = false
        console.log(id, dynamicIndex)
        if (dynamicIndex >= 0) {
          const literalIdMatch = id.match(/^(?:'([^']+)'|"([^"]+)")$/)
          if (literalIdMatch) {
            hasLiteralDynamicId = true
            id = literalIdMatch[1] || literalIdMatch[2]
          }
        }
        if (dynamicIndex === -1 || hasLiteralDynamicId) {
          // do not rewrite external imports
          if (isExternalUrl(id)) {
            continue
          }

          const resolved = resolveImport(
            root,
            importer,
            id,
            resolver,
            timestamp
          )

          if (resolved !== id) {
            debug(`    "${id}" --> "${resolved}"`)
            s.overwrite(
              start,
              end,
              // 由于lexer的分析结果对于动态导入的情况会包含外层的引号,所以这里我们需要手动添加,否则最终的结果将不存在引号导致报错
              hasLiteralDynamicId ? `'${resolved}'` : resolved
            )
            hasReplaced = true
          }

          // save the import chain for hmr analysis
          const importee = cleanUrl(resolved)
          if (
            importee !== importer &&
            // no need to track hmr client or module dependencies
            importee !== clientPublicPath
          ) {
            currentImportees.add(importee)
            debugHmr(`        ${importer} imports ${importee}`)
            ensureMapEntry(importerMap, importee).add(importer)
          }
        } else if (id !== 'import.meta') {
          console.warn(
            chalk.yellow(`[vite] ignored dynamic import(${id}) in ${importer}.`)
          )
        }
      }

      if (hasHMR) {
        debugHmr(`rewriting ${importer} for HMR.`)
        rewriteFileWithHMR(root, source, importer, resolver, s)
        hasReplaced = true
      }

      if (hasEnv) {
        debug(`    injecting import.meta.env for ${importer}`)
        s.prepend(
          `import __VITE_ENV__ from "${envPublicPath}"; ` +
            `import.meta.env = __VITE_ENV__; `
        )
        hasReplaced = true
      }

   	// 因为importees可能因为编辑而发生了变化。
      // 检查我们是否需要从某些进口商那里删除这个importees。
      if (prevImportees) {
        prevImportees.forEach((importee) => {
          if (!currentImportees.has(importee)) {
            const importers = importerMap.get(importee)
            if (importers) {
              importers.delete(importer)
            }
          }
        })
      }

      if (!hasReplaced) {
        debug(`    nothing needs rewriting.`)
      }

      return hasReplaced ? s.toString() : source
    }

上面的代码主要做了两件事:

  1. 遍历所有imports依赖,建立依赖关系,解析真实路径,返回代码
  2. 删掉老依赖

获取真实路径部分是 resolveImport。

resolveImport

直接将裸模块名称解析为其入口路径,以便从它那里的相对的导入(包括源码地图的URL)可以正常工作。 eg, import from 'vue' 。 处理裸模块。从模块的 package.json 中找到entry字段并且返回,这里 Vue 的 entry 是 '@vue/shared/dist/shared.esm-bundler.js', 由于 Vite 在预优化时对所有 package.json 中的 dependencies 模块进行了预优化,所以返回的是统一 optimize 后的路径。

if (bareImportRE.test(id)) {
    id = `/@modules/${resolveBareModuleRequest(root, id, importer, resolver)}`
  } else {
    // 1. relative to absolute 相对路径变成绝对路径
    //    ./foo -> /some/path/foo
    let { pathname, query } = resolver.resolveRelativeRequest(importer, id)

    // 2. 标准化路径,兼容不同的操作系统
    pathname = resolver.normalizePublicPath(pathname)

    // 3. mark non-src imports 记录没有query参数且后缀名不是js  jsx vue .mjs 的操作。例如 import './index.css' import png from 'xxx.png' 在后面加上 import query
    if (!query && path.extname(pathname) && !jsSrcRE.test(pathname)) {  // 不是模板 && 有扩展名 && 没有query
      query += `?import`
    }

    id = pathname + query
  }

  // 4. 通过添加时间戳来强制重新获取脏的导入数据
  if (timestamp) {
    const dirtyFiles = hmrDirtyFilesMap.get(timestamp)
    const cleanId = cleanUrl(id)
    if (dirtyFiles && dirtyFiles.has(cleanId)) {
      // 1. 标志是来自脏文件的更新
      id += `${id.includes(`?`) ? `&` : `?`}t=${timestamp}`
    } else if (latestVersionsMap.has(cleanId)) {
      // 2. 这个文件以前是热更新的,有一个更新的版本
      id += `${id.includes(`?`) ? `&` : `?`}t=${latestVersionsMap.get(cleanId)}`
      console.log('latestVersionsMap:::', id, timestamp)
    }
  }
  return id

image:359304E2-F3E5-491D-B67C-109B8A7F68AF-533-0000DE3390338E99/helloworldhmr.a5a0fc36.png

参考: https://vite-design.surge.sh/ 面向未来的前端构建工具 - vite - 知乎 Vite 2.0 预构建源码解析 - 知乎 https://zhuanlan.zhihu.com/p/371363712 https://www.qiyuandi.com/zhanzhang/zonghe/13757.html How Snowpack Works 聊聊 ESM、Bundle 、Bundleless 、Vite 、Snowpack - SegmentFault 思否 Vite源码解析(三)之热更新篇 - 知乎

新闻

React 18 发布计划 坑都占好了 https://github.com/reactwg/react-18

Vue 3.1.0 Pluto 发布

https://juejin.cn/post/6971311349267709966

eslint 8.0.0 来了

https://eslint.org/blog/2021/06/whats-coming-in-eslint-8.0.0

rust 2021

https://blog.rust-lang.org/2021/05/11/edition-2021.html

CSS 定位有了新属性 inset

https://developer.mozilla.org/en-US/docs/Web/CSS/inset

/* <length> values */
inset: 10px; /* value applied to all edges */
inset: 4px 8px; /* top/bottom left/right */
inset: 5px 15px 10px; /* top left/right bottom */
inset: 2.4em 3em 3em 3em; /* top right bottom left */

/* <percentage>s of the width (left/right) or height (top/bottom) of the containing block */
inset: 10% 5% 5% 5%;

/* Keyword value */
inset: auto;

/* Global values */
inset: inherit;
inset: initial;
inset: unset;

vue 版本号

https://juejin.cn/post/6972098481510940702