干货
React
Vue
工程化
前瞻
life.caoover 4 years

如何使用Xcode调试web在iOS10.x.x兼容问题(含破解文件)

一、问题概述 开发中遇到 iOS10.x.x 版本的兼容问题,找不到古董版本真机,新版 Xcode 里 Simulator 中没有了 iOS10.x.x ,找到方法破解,如下: 1.Xcode配置 新建项目后,发现新版Xcode设备里是iphone8,且系统是13.0 或 15.0 使用以下步骤把屏蔽的旧机型和系统显示出来 2.替换Info.plist文件 打开终端cd 到如下目录,然后open ./打开当前目录,如图 /Library/Developer/CoreSimulator/Profiles/Runtimes 选中iOS 10.3.simruntime 右键选中 显示包内容,打开Contents目录,替换掉Info.plist文件 Info.plist 3.下载此文件,覆盖后,重启(Xcode),还不行,重启电脑,重启大法好 重启后,打开Xcode,打开 demo项目 选中自己的PROJECT, Target选中你想要的版本,比如10.0,这时候就出现久违的古董机型和系统了 4.配置完成,以后无需打开Xcode,直接选中Simulator即可,Device 会有OS 10.3 二、利用mac Safari浏览器像调试pc一样调试模拟器内的web 步骤跟调试真机很相似,熟悉的同学可略过 1.打开需要调试的模拟器和系统(eg: iphone6 10.3) 打开模拟器里的Safari,输入想要调试的web url 2.打开Safari浏览器,并打开"开发菜单" 3.选中"开发"Tab,找到想要调试页面 4.网页检查器出现,调试起来吧

640
life.caoover 3 years

Web Components原生支持的组件化

一、前因 十一国庆期间,看到大街小巷的红旗飘飘,于是乎想到,可否用H5做个动画国旗,发现了个有趣的东东, 一个html静态文件,出现了一个自定义组件?炫酷的动画,有点撩,很难不想了解它一下点击体验,不慎入坑Web Components。 二、 fancy-components 国旗动画的来源 HTML <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>fancy-components</title> </head> <body> <fc-china></fc-china> <script type="module"> import { FcChina } from './fancy-components/index.js' // new 就相当于全局注册了这个组件,相当于 Vue 的 Vue.component('FcChina', 组件) new FcChina() </script> </body> </html> fancy-components:直译 - 花式组件库,一个 Web Components** 组件库**。接下来介绍下到底什么是 Web Components吧。 三、什么是 Web Components Google一直在推动的浏览器 原生组件 ,即 Web Components API。 它就是为了解决"组件化"而诞生的,它是浏览器原生支持的组件化,不依赖任何库、依赖和打包工具就可以在浏览器中运行。Web Components 不是一门单一的技术,而是四门技术的组合,这四门技术分别是: Shadow DOM HTML templates Custom Elements HTML Imports(已废弃不再介绍) Shadow DOM和 HTML templates( and slots) 类似 Vue 的 Slot,不作详解,有兴趣的同学可点击上面来自MDN的链接学习。下面就介绍里面最重要的一项技术,同时也是所有浏览器都没有提出反对意见,一致通过的一项技术 —— Custom Elements** (自定义元素)**。 四、Custom Elements 基础使用 它使开发者能够将 HTML 页面 的功能封装为 custom elements(自定义标签),window 全局对象上有一个 customElements 提供自定义元素支持,它包含四个 API: define:注册/定义自定义元素 get:获取自定义元素的构造函数 whenDefined upgrade define 注册组件(自定义元素),不能写成自闭和标签,浏览器会当作起始标签包裹后面的全部内容 HTML <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>Custom Elements</title> </head> <body> <!-- 使用元素 --> <huo-la-la></huo-la-la> <!-- 不能写成自闭和标签,浏览器会当作起始标签包裹后面的全部内容 --> <!-- <huo-la-la /> --> <script> // 注册组件(自定义元素) window.customElements.define( // 参数1:元素名,必须包含一个短横线,以区分原生元素 'huo-la-la', // 参数2:用于定义元素行为的类(类似 React 中的类组件),必须继承自 HTMLElement class extends HTMLElement { constructor() { super() // custome elements 类中的 this 指向组件本身 console.log(this) this.innerHTML = '<h1>货拉拉</h1>' this.onclick = () => alert('啥车都有') } } ) // 多次注册相同名称的组件会报错: // the name "huo-la-la" has already been used with this registry // window.customElements.define('huo-la-la', class extends HTMLElement {}) </script> </body> </html> get _获取第三方组件的构造函数,用于继承扩展,_给上面国旗案例扩展一个点击事件 HTML <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>fancy-components-click</title> </head> <body> <my-fc-china click></my-fc-china> <script type="module"> // 要在服务器环境下运行,建议 http-server import { FcChina } from './fancy-components/index.js' // new 就相当于全局注册了这个组件,相当于 Vue 的 Vue.component('FcChina', 组件) new FcChina() // 获取第三方组件的构造函数,用于继承扩展 const FcChinaConstructor = customElements.get('fc-china') customElements.define( 'my-fc-china', class extends FcChinaConstructor { constructor() { super() this.onclick = () => console.log('自定义点击事件') } } ) </script> </body> </html> whenDefined whenDefined是元素定义后触发的回调,接口返回Promise,通常用于异步注册组件的时候 <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>whenDefined</title> </head> <body> <huo-la-la>加载中...</huo-la-la> <script> // 模拟 JS 代码执行延迟 setTimeout(() => { customElements.define( 'huo-la-la', class extends HTMLElement { } ) }, 2000) // 返回一个 Promise customElements .whenDefined('huo-la-la') .then(() => { document.querySelector('huo-la-la').innerHTML = '货拉拉' }) .catch(err => console.log(err)) </script> </body> </html> upgrade 如果在定义元素之前先使用 JS 创建了元素,则元素实例并不是继承的定义元素行为的类,可以使用upgrade HTML <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>upgrade</title> </head> <body> <script> // 先使用 JS 创建自定义元素 const el = document.createElement('huo-la-la') // 再注册自定义元素 class Huolala extends HTMLElement { } customElements.define('huo-la-la', Huolala) // 返回 false console.log(el instanceof Huolala) // 升级元素 customElements.upgrade(el) // 返回 true console.log(el instanceof Huolala) </script> </body> </html> 五、Web Components 对 Vue 的影响 潜看一段Vue和Web Components引入组件的对比 Vue 组件 my-span.vue <!-- my-span.vue --> <template> <span>my-span</span> </template> <script> export default {}; </script> <style> span { color: purple; } </style> 只需要将它稍微修改一下,它就会变成 Web Components 文件,能够直接在浏览器中运行,不用像vue那么复杂需要node、webpack、loader等打包后,浏览器才能识别。 Web Components组件 my-span.html HTML <!-- my-span.html --> <template> <span>my-span</span> </template> <script> // 获取 DOM 元素 const dom = document.currentScript.ownerDocument.querySelector('template').content // 有点像 React 定义组件的写法 class MySpan extends HTMLElement { constructor() { super() this.attachShadow({ mode: 'open' }).appendChild(dom) } } // 注册组件 customElements.define('my-span', MySpan) </script> <style> span { color: purple; } </style> 使用 HTML Imports 在 HTML 页面中引入组件: HTML <!-- index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Document</title> <!-- HTML Imports --> <link rel="import" href="my-span.html"> </head> <body> <my-span></my-span> </body> </html> 当看到这里的时候,感觉,这不就是跟vue很像,不对,应该是vue跟Web Components很像很像,其实并不是巧合,尤大在创作Vue时,大量参考了Web Components的语法。 上文提到HTML Imports已废弃,上文只是为了对比当时跟vue的相似度,加以修改,只需要用js生成dom即可,my-span.html 改为my-span.js // my-span.js class MySpan extends HTMLElement { constructor() { super() this.render() } // 生成 HTML 和 CSS render() { const shadow = this.attachShadow({ mode: 'open' }) const dom = document.createElement('span') const style = document.createElement('style') // dom.textContent = 'my-span' dom.innerHTML = ` <slot>默认内容</slot> <slot name="content">默认内容</slot> ` style.textContent = ` span { color: purple; } ` shadow.appendChild(style) shadow.appendChild(dom) } } // 注册组件 customElements.define('my-span', MySpan) index.html <!-- index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Document</title> <!-- HTML Imports(已废弃) --> <!-- <link rel="import" href="my-span.html"> --> <!-- ES Modules --> <script type="module" src="my-span.js"></script> </head> <body> <my-span> <h1>默认插槽</h1> <h2 slot="content">具名插槽</h2> </my-span> </body> </html> slot的使用是不是vue也跟Web Components很像。Vue和React的官网也提到了和Web Components的关系,Vue 与 Web Components、在React 中使用 Web Components。 六、推荐 Web Components 的一些学习路径 本文也参考了如下的文档和博客内容,想深入学习的同学可参考: 官方-需梯子 MDN Web Docs Web Component可以取代你的前端框架吗? 本文demo的代码包: web-componets.zip 注:使用 ES Modules ,都需要开启一个 Web 服务,使用 VS Code 的 Live Server 插件或者使用http-server。

252
life.caoabout 6 years

Gridsome 四部曲之介绍 (一)

是什么 GitHub 仓库:https://github.com/gridsome/gridsome 官网:https://gridsome.org/ Gridsome 是由Vue.js驱动的Jamstack框架,用于构建默认情况下快速生成的静态生成的网站和应用。 Gridsome是Vue提供支持的静态站点生成器,用于为任何无头CMS,本地文件或API构建可用于CDN的网站 使用Vue.js,webpack和Node.js等现代工具构建网站。通过npm进行热重载并访问任何软件包,并使用自动前缀在您喜欢的预处理器(如Sass或Less)中编写CSS。 基于 Vue.js 的 Jamstack 框架 Gridsome 使开发人员可以轻松构建默认情况下快速生成的静态生成的网站和应用程序 Gridsome允许在内容里面引用任何CMS或数据源。 从WordPress,Contentful或任何其他无头CMS或API中提取数据,并在组件和页面中使用GraphQL访问它。 为什么选择 Gridsome Vue.js for frontend - The simplest & most approachable frontend framework. Data sourcing - Use any Headless CMSs, APIs or Markdown-files for data. Local development with hot-reloading - See code changes in real-time. File-based page routing - Any Name.vue file in src/pages is a static route. Dynamic routing - Any [param].vue file in src/pages is a dynamic route. Static file generation - Deploy securely to any CDN or static web host. GraphQL data layer - Simpler data management with a centralized data layer. Automatic Code Splitting - Builds ultra performance into every page. Plugin ecosystem - Find a plugin for any job. 什么是 Jamstack Gridsome是一个Jamstack框架。 Jamstack使您可以通过预渲染文件并直接从CDN直接提供文件来构建快速安全的站点和应用程序,而无需管理或运行Web服务器。 Learn more about the Jamstack. 它是如何工作的 Gridsome生成静态HTML,一旦加载到浏览器中,该HTML就会渗入Vue SPA。这意味着您可以使用Gridsome构建静态网站和动态应用程序。 Gridsome为每个页面构建一个.html文件和一个.json文件。加载第一页后,它仅使用.json文件来预取和加载下一页的数据。它还为需要它的每个页面构建一个.js包(代码拆分)。 它使用vue-router进行SPA路由,并使用vue-meta来管理。 Gridsome默认添加最小57kB的gzip JS捆绑包大小(vue.js,vue-router,vue-meta和一些用于图像延迟加载的文件)。 详细了解其工作原理 学习条件 您应该具有有关HTML,CSS,Vue.js以及如何使用终端的基本知识。了解GraphQL的工作原理是有好处的,但不是必需的。 Gridsome是学习它的好方法。 Gridsome 需要Node.js(v8.3 +),并建议使用 Yarn。 备选方案 VuePress Nuxt Gatsby.js 使用场景 不适合管理系统 简单页面展示 想要有更好的 SEO 想要有更好的渲染性能

56
life.caoabout 6 years

Gridsome 四部曲之起步 (二)

起步 目标:快速了解 Gridsome 项目 1、安装 Gridsome CLI # 使用 yarn yarn global add @gridsome/cli # 使用 npm npm install --global @gridsome/cli # 查看是否安装成功 gridsome --version 2、创建 Gridsome 项目 # 创建项目 gridsome create my-gridsome-site # 进入项目中 cd my-gridsome-site # 启动开发模式,或 npm run develop gridsome develop gridsome 项目安装依赖注意事项: 配置 node-gyp 编译环境 https://github.com/nodejs/node-gyp 配置环境变量:npm_config_sharp_libvips_binary_host 为 https://npm.taobao.org/mirrors/sharp-libvips/ https://github.com/lovell/sharp-libvips https://developer.aliyun.com/mirror/NPM https://npm.taobao.org/mirrors https://sharp.pixelplumbing.com/installnpm config set sharp_binary_host "https://npm.taobao.org/mirrors/sharp" npm config set sharp_libvips_binary_host "https://npm.taobao.org/mirrors/sharp-libvips" 配置 hosts:199.232.68.133 raw.githubusercontent.com https://www.ipaddress.com/ 3、目录结构 . ├── src │ ├── components # 公共组件 │ ├── layouts # 布局组件 │ ├── pages # 页面路由组件 │ ├── templates # 模板文件 │ ├── favicon.png # 网站图标 │ └── main.js # 应用入口 ├── static # 静态资源存储目录,该目录中的资源不做构建处理 ├── README.md ├── gridsome.config.js # 应用配置文件 ├── gridsome.server.js # 针对服务端的配置文件 ├── package-lock.json └── package.json 4、自己试一试 在 src/pages 目录中创建一个 .vue 组件 5、构建 gridsome build 构建结果默认输出到 dist 目录中。 Gridsome 会把每个路由文件构建为独立的 HTML 页面。 6、部署 可以把构建结果 dist 放到任何 Web 服务器中进行部署。 例如我们这里使用 Node.js 命令行工具 serve 来测试构建结果。 npm install -g serve serve dist 或者可以部署到其它第三方托管平台:https://gridsome.org/docs/deployment/。 或是自己的服务器,都可以! 核心概念 目标:学习 Gridsome 的核心概念 Pages 通过在 src/pages 文件夹中添加Vue组件来创建页面。他们使用基于文件的路由系统。例如,src / pages / About.vue将是 mywebsite.com/about/。页面用于简单页面和列出集合的页面(例如/ blog /)。 了解有关页面的更多信息:https://gridsome.org/docs/pages/。 Collections 如果您要在网站上放置博客文章,标签,产品等,则收藏很有用。可以使用 Source插件或 Data Store API 从任何Headless CMS,内容API或Markdown文件中获取集合。 集合存储在临时的本地GraphQL数据层中,可以在任何地方查询,过滤,分页或有关系。 Templates 模板负责显示集合的节点(单个页面)。模板通常位于src / templates中。如果未在模板配置中指定组件,则Gridsome尝试查找与集合名称相同的文件。 这是一个例子: <!-- src/templates/Post.vue --> <template> <Layout> <h1 v-html="$page.post.title" /> </Layout> </template> <page-query> query ($id: ID!) { post(id: $id) { title } } </page-query> 更多关于 Templates 的内容:https://gridsome.org/docs/templates/。 Layouts 布局是在页面和模板内部用于包装内容的Vue组件。布局通常包含页眉和页脚。 页面中通常按以下方式使用布局: <template> <Layout> <h1>About us</h1> </Layout> </template> <script> import Layout from '~/layouts/Default.vue' export default { components: { Layout } } </script> 也可以在全局范围内使用布局,因此您无需每页导入它们。 请注意,Gridsome CLI创建的默认模板将使用全局布局组件。 更多关于 Layouts 的内容:https://gridsome.org/docs/layouts/。 Images Gridsome具有内置的 <g-image> 组件,可输出优化的逐行图像。如果更改宽度和高度,则在开发时还可以实时调整大小和裁剪。 <g-images> 创建一个超小型模糊的嵌入式base64图像,然后在视图中使用IntersectionObserver延迟加载图像。 更多关于 Images 的内容:https://gridsome.org/docs/images/。 Linking Gridsome具有内置的 <g-link> 组件,该组件在查看链接时使用 IntersectionObserver 来预取链接的页面。这使得在 Gridsome 站点中浏览非常快,因为单击的页面已经下载。 更多关于 <g-link> 的内容:https://gridsome.org/docs/linking/。 部署 https://gridsome.org/docs/deployment/

25
life.caoabout 6 years

Gridsome 四部曲之基础 (三)

Gridsome 基础 目录结构 . ├── package.json # 包说明文件 ├── gridsome.config.js # Gridsome 配置文件 ├── gridsome.server.js # 自定义 Gridsome 编译 ├── static/ # 静态资源存储目录,该目录中的资源不做构建处理 └── src/ ├── main.js # 应用入口 ├── index.html # 公共页面 ├── App.vue # 根组件 ├── layouts/ # 布局组件 │ └── Default.vue ├── pages/ # 路由页面 │ ├── Index.vue │ └── Blog.vue └── templates/ # 模板 └── BlogPost.vue 项目配置 Gridsome需要 gridsome.config.js 才能工作。插件和项目设置位于此处。基本配置文件如下所示: module.exports = { siteName: 'Gridsome', siteUrl: 'https://www.gridsome.org', plugins: [] } 属性 类型 默认值 说明 siteName string <dirname> 该名称通常在标题标签中使用。 siteDescription string '' 页面描述,<meta name="description" content="xxx"> pathPrefix string '' Gridsome假定您的项目是从域的根目录提供的。如果您的项目将托管在名为my-app的子目录中,则将此选项更改为“ / my-app”。 titleTemplate string %s - <siteName> 设置标题标签的模板。 %s占位符将替换为您在页面中设置的metaInfo的标题。 plugins Array [] 通过将插件添加到plugins数组来激活插件。 templates object {} 定义 collections 的路由和模板。 metadata object {} 将全局元数据添加到GraphQL模式。 icon string | object './src/favicon.png' Gridsome默认情况下会将位于src / favicon.png的任何图像用作favicon和touchicon,但您可以定义其他路径或大小等。图标应为正方形且至少16个像素。网站图标将调整为16、32、96像素。默认情况下,触摸图标的大小将调整为76、152、120、167、180像素。 configureWebpack object | Function 如果该选项是一个对象,它将与内部配置合并。 chainWebpack Function 该函数将接收由webpack-chain驱动的ChainableConfig实例。 runtimeCompiler boolean false 在运行时包括Vue模板编译器。 configureServer Function 配置开发服务器。 permalinks.trailingSlash boolean true 默认情况下,在页面和模板后添加斜杠。启用此选项后,具有动态路由的页面将不包含尾部斜杠,并且服务器上必须具有额外的重写规则才能正常工作。另外,的静态路径不会自动包含尾部斜杠,而应包含在路径中: permalinks.slugify 使用自定义的Slugify方法。默认是 @sindresorhus/slugify css.split boolean false 将CSS分成多个块。默认情况下禁用拆分。拆分CSS可能会导致奇怪的行为。 css.loaderOptions Object {} 将选项传递给与CSS相关的 loader host string localhost port number 8080 outputDir string ‘dist’ 运行gridsome构建时将在其中生成生产构建文件的目录。 插件示例: module.exports = { plugins: [ { use: '@gridsome/source-filesystem', options: { path: 'blog/**/*.md', route: '/blog/:year/:month/:day/:slug', typeName: 'Post' } } ] } 注意事项: 开发过程中修改配置需要重启服务 Pages 页面 页面负责在URL上显示您的数据。每个页面将静态生成,并具有自己的带有标记的index.html文件。 在Gridsome中创建页面有两种选择: 单文件组件 使用 Pages API 以编程方式创建页面 pages 中的单文件组件 src/pages 目录中的单文件组件将自动具有其自己的URL。文件路径用于生成 URL,以下是一些基本示例: src/pages/Index.vue becomes /(The frontpage) src/pages/AboutUs.vue becomes /about-us/ src/pages/about/Vision.vue becomes /about/vision/ src/pages/blog/Index.vue becomes /blog/ 大小自动转小写,驼峰命名会自动使用短横杠分割 src/pages 中的页面通常用于诸如 /about/ 之类的固定 URL,或用于在 /blog/ 等处列出博客文章。 使用 Pages API 创建页面 可以使用 gridsome.server.js 中的 createPages 钩子以编程方式创建页面。如果您要从外部 API 手动创建页面而不使用 GraphQL 数据层,则此功能很有用。 module.exports = function (api) { api.createPages(({ createPage }) => { createPage({ path: '/my-page', component: './src/templates/MyPage.vue' }) }) } 动态路由 动态路由对于仅需要客户端路由的页面很有用。例如,根据URL中的细分从生产环境中的外部API获取信息的页面。 通过文件创建动态路由 动态页面用于客户端路由。可以通过将名称包装在方括号中来将路由参数放置在文件和目录名称中。例如: src/pages/user/[id].vue becomes /user/:id. src/pages/user/[id]/settings.vue becomes /user/:id/settings. 注意事项: 在构建时,这将生成 user/_id.html 和 user/_id/settings.html,并且您必须具有重写规则以使其正常运行。 具有动态路由的页面的优先级低于固定路由。例如,如果您有一个 /user/create 路由和 /user/:id 路由,则 /user/create 路由将具有优先级。 这是一个基本的页面组件,它使用路由中的id参数来获取客户端的用户信息: <template> <div v-if="user"> <h1>{{ user.name }}</h1> </div> </template> <script> export default { data() { return { user: null } }, async mounted() { const { id } = this.$route.params const response = await fetch(`https://api.example.com/user/${id}`) this.user = await response.json() } } </script> 始终使用 mounted 来获取客户端数据。由于在生成静态HTML时执行数据,因此在 created 中获取数据会引起问题。 通过编程方式创建动态路由 以编程方式创建带有动态路由的页面,以获取更高级的路径。动态参数使用 : 来指定。 每个参数都可以具有一个自定义的正则表达式,以仅匹配数字或某些值。 module.exports = function (api) { api.createPages(({ createPage }) => { createPage({ path: '/user/:id(\\d+)', component: './src/templates/User.vue' }) }) } 生成重写规则 Gridsome无法为动态路由的每种可能的变体生成HTML文件,这意味着直接访问URL时最有可能显示404页。而是,Gridsome生成一个HTML文件,该文件可用于重写规则。例如,类似/ user /:id的路由将生成位于/user/_id.html的HTML文件。您可以具有重写规则,以将所有与/ user /:id匹配的路径映射到该文件。 由于每种服务器类型都有自己的语法,因此必须手动生成重写规则。 afterBuild 挂钩中的 redirects 数组包含应生成的所有必要的重写规则。 const fs = require('fs') module.exports = { afterBuild ({ redirects }) { for (const rule of redirects) { // rule.from - The dynamic path // rule.to - The HTML file path // rule.status - 200 if rewrite rule } } } 页面 meta 信息 Gridsome 使用 vue-meta 处理有关页面的元信息。 <template> <div> <h1>Hello, world!</h1> </div> </template> <script> export default { metaInfo: { title: 'Hello, world!', meta: [ { name: 'author', content: 'John Doe' } ] } } </script> 自定义 404 页面 创建一个 src/pages/404.vue 组件以具有一个自定义 404 页面。 Collections 集合 集合是一组节点,每个节点都包含带有自定义数据的字段。如果您要在网站上放置博客文章,标签,产品等,则集合很有用。 添加集合 集合可以通过 source plugins 添加,也可以使用 Data Store API 自己添加。 在开发和构建期间,这些集合存储在本地内存数据存储中。节点可以来自本地文件(Markdown,JSON,YAML等)或任何外部API。 Collections 使用 source plugins 添加集合 将集合添加到 Gridsome 的最简单方法是使用源插件。本示例从 WordPress 网站创建集合。源插件的 typeName 选项通常用于为插件添加的集合名称添加前缀。 // gridsome.config.js module.exports = { plugins: [ { use: '@gridsome/source-wordpress', options: { baseUrl: 'YOUR_WEBSITE_URL', typeName: 'WordPress', } } ] } 你可以在这里浏览插件列表。 使用 Data Store API 添加集合 您可以从任何外部 API 手动添加集合。 本示例创建一个名为 Post 的集合,该集合从 API 获取内容并将结果作为节点添加到该集合中。 // gridsome.server.js const axios = require('axios') module.exports = function (api) { api.loadSource(async actions => { const collection = actions.addCollection('Post') const { data } = await axios.get('https://api.example.com/posts') for (const item of data) { collection.addNode({ id: item.id, title: item.title, content: item.content }) } }) } 了解有关 Data Store API 的更多信息。

29
life.caoabout 6 years

Gridsome 四部曲之处理数据GraphQL (四)

处理数据 大家可能会有疑惑,不是建静态博客么,怎么会有 GraphQL?难道还要部署服务器? 其实这里 GraphQL 并不是作为服务器端部署,而是作为 Gridsome 在本地管理资源的一种方式。 通过 GraphQL 统一管理实际上非常方便,因为作为一个数据库查询语言,它有非常完备的查询语句,与 JSON 相似的描述结构,再结合 Relay 的 Connections 方式处理集合,管理资源不再需要自行引入其它项目,大大减轻了维护难度。 GraphQL数据层 Import data GraphQL数据层是在开发模式下可用的工具。这是临时存储到 Gridsome 项目中的所有数据的地方。可以将其视为可帮助您更快更好地处理数据的本地数据库。 来自 GraphQL 数据层的数据将生成为静态内容。 数据层和导入数据的源之间没有实时连接。这意味着您需要重新生成网站以获取最新的数据更新。 如果需要动态数据,则应使用客户端数据。 提示:默认情况下,Pages 也 Site metadata 已添加到数据层。 处理数据 How to import data. How to query data. How to filter data. How to create taxonomy pages. How to paginate data. How to add client-side / dynamic data. GraphQL资源管理器 每个 Gridsome 项目都有一个 GraphQL 资源管理器,可以在开发模式下使用它来探索和测试查询。 在这里,您还将获得所有可用 GraphQL 集合的列表。 通常可以通过转到 http:// localhost:8080/___explore 来打开它。 graphql-explorer 导入数据 Gridsome 使您可以将数据从任何数据源导入 GraphQL 数据层。 使用 source plugins 使用外部 API 使用本地文件 Markdown Images YAML CSV JSON 查询数据 您可以将数据从GraphQL数据层查询到任何页面,模板或组件中。在Vue组件中,使用 <page-query> 或 <static-query> 块添加查询。 在 Pages 和 Templates 中使用 <page-query> 在 Components 中使用 <static-query> 如何使用 GraphQL 查询 在 Gridsome 中使用 GraphQL 很容易,并且您不需要了解 GraphQL。 这是一个如何在页面的 page-query 中使用GraphQL的示例: <template> <div> <div v-for="edge in $page.posts.edges" :key="edge.node.id"> <h2>{{ edge.node.title }}</h2> </div> </div> </template> <page-query> query { posts: allWordPressPost { edges { node { id title } } } } </page-query> 使用 GraphQL,您仅查询所需的数据。这使得处理数据更加容易和整洁。 查询总是从 query 开始 然后是 Posts(可以是任何东西) 然后写一些内容例如 posts: allWordPressPost。 allWordPressPost 是您要查询的GraphQL集合的名称。 posts: 部分是可选的别名。 使用 posts 作为别名时,您的数据将位于 $page.posts(如果使用 <static-query>,则为 $static.posts)。否则,它将在 $page.allWordPressPost 上可用。 学习更多关于 GraphQL 查询的内容:https://graphql.org/learn/queries/。

27
life.caoover 2 years

Virtual DOM和Virtual DOM库——Vue虚拟dom的借鉴

什么是 Virtual DOM **Virtual DOM(虚拟 DOM)**,是由普通的 JS 对象来描述 DOM 对象,因为不是真实的 DOM 对象,所以叫 Virtual DOM 真实 DOM 成员 let element = document.querySelector('#app') let s = '' for (var key in element) { s += key + ',' } console.log(s) // 打印结果 align,title,lang,translate,dir,hidden,accessKey,draggable,spellcheck,autocapitalize,contentEditable,isContentEditable,inputMode,offsetParent,offsetTop,offsetLeft,offsetWidth,offsetHeight,style,innerText,outerText,oncopy,oncut,onpaste,onabort,onblur,oncancel,oncanplay,oncanplaythrough,onchange,onclick,onclose,oncontextmenu,oncuechange,ondblclick,ondrag,ondragend,ondragenter,ondragleave,ondragover,ondragstart,ondrop,ondurationchange,onemptied,onended,onerror,onfocus,oninput,oninvalid,onkeydown,onkeypress,onkeyup,onload,onloadeddata,onloadedmetadata,onloadstart,onmousedown,onmouseenter,onmouseleave,onmousemove,onmouseout,onmouseover,onmouseup,onmousewheel,onpause,onplay,onplaying,onprogress,onratechange,onreset,onresize,onscroll,onseeked,onseeking,onselect,onstalled,onsubmit,onsuspend,ontimeupdate,ontoggle,onvolumechange,onwaiting,onwheel,onauxclick,ongotpointercapture,onlostpointercapture,onpointerdown,onpointermove,onpointerup,onpointercancel,onpointerover,onpointerout,onpointerenter,onpointerleave,onselectstart,onselectionchange,onanimationend,onanimationiteration,onanimationstart,ontransitionend,dataset,nonce,autofocus,tabIndex,click,focus,blur,enterKeyHint,onformdata,onpointerrawupdate,attachInternals,namespaceURI,prefix,localName,tagName,id,className,classList,slot,part,attributes,shadowRoot,assignedSlot,innerHTML,outerHTML,scrollTop,scrollLeft,scrollWidth,scrollHeight,clientTop,clientLeft,clientWidth,clientHeight,attributeStyleMap,onbeforecopy,onbeforecut,onbeforepaste,onsearch,elementTiming,previousElementSibling,nextElementSibling,children,firstElementChild,lastElementChild,childElementCount,onfullscreenchange,onfullscreenerror,onwebkitfullscreenchange,onwebkitfullscreenerror,setPointerCapture,releasePointerCapture,hasPointerCapture,hasAttributes,getAttributeNames,getAttribute,getAttributeNS,setAttribute,setAttributeNS,removeAttribute,removeAttributeNS,hasAttribute,hasAttributeNS,toggleAttribute,getAttributeNode,getAttributeNodeNS,setAttributeNode,setAttributeNodeNS,removeAttributeNode,closest,matches,webkitMatchesSelector,attachShadow,getElementsByTagName,getElementsByTagNameNS,getElementsByClassName,insertAdjacentElement,insertAdjacentText,insertAdjacentHTML,requestPointerLock,getClientRects,getBoundingClientRect,scrollIntoView,scroll,scrollTo,scrollBy,scrollIntoViewIfNeeded,animate,computedStyleMap,before,after,replaceWith,remove,prepend,append,querySelector,querySelectorAll,requestFullscreen,webkitRequestFullScreen,webkitRequestFullscreen,createShadowRoot,getDestinationInsertionPoints,ELEMENT_NODE,ATTRIBUTE_NODE,TEXT_NODE,CDATA_SECTION_NODE,ENTITY_REFERENCE_NODE,ENTITY_NODE,PROCESSING_INSTRUCTION_NODE,COMMENT_NODE,DOCUMENT_NODE,DOCUMENT_TYPE_NODE,DOCUMENT_FRAGMENT_NODE,NOTATION_NODE,DOCUMENT_POSITION_DISCONNECTED,DOCUMENT_POSITION_PRECEDING,DOCUMENT_POSITION_FOLLOWING,DOCUMENT_POSITION_CONTAINS,DOCUMENT_POSITION_CONTAINED_BY,DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC,nodeType,nodeName,baseURI,isConnected,ownerDocument,parentNode,parentElement,childNodes,firstChild,lastChild,previousSibling,nextSibling,nodeValue,textContent,hasChildNodes,getRootNode,normalize,cloneNode,isEqualNode,isSameNode,compareDocumentPosition,contains,lookupPrefix,lookupNamespaceURI,isDefaultNamespace,insertBefore,appendChild,replaceChild,removeChild,addEventListener,removeEventListener,dispatchEvent 可以使用 Virtual DOM 来描述真实 DOM,示例 { sel: "div", data: {}, children: undefined, text: "Hello Virtual DOM", elm: undefined, key: undefined } 为什么使用 Virtual DOM 手动操作 DOM 比较麻烦,还需要考虑浏览器兼容性问题,虽然有 jQuery 等库简化 DOM 操作,但是随着项目的复杂 DOM 操作复杂提升 为了简化 DOM 的复杂操作于是出现了各种 MVVM 框架,MVVM 框架解决了视图和状态的同步问题 为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题,于是 Virtual DOM 出现了 Virtual DOM 的好处是当状态改变时不需要立即更新 DOM,只需要创建一个虚拟树来描述 DOM, Virtual DOM 内部将弄清楚如何有效(diff)的更新 DOM 参考 github 上 virtual-dom 的描述 虚拟 DOM 可以维护程序的状态,跟踪上一次的状态 通过比较前后两次状态的差异更新真实 DOM 虚拟 DOM 的作用 维护视图和状态的关系 复杂视图情况下提升渲染性能 除了渲染 DOM 以外,还可以实现 SSR(Nuxt.js/Next.js)、原生应用(Weex/React Native)、小程序(mpvue/uni-app)等 image-20200102104642121 Virtual DOM 库 SnabbdomVue 2.x 内部使用的 Virtual DOM 就是改造的 Snabbdom 大约 200 SLOC(single line of code) 通过模块可扩展 源码使用 TypeScript 开发 最快的 Virtual DOM 之一 virtual-dom 案例演示 jQuery-demo snabbdom-demo

40
life.caoover 2 years

Snabbdom 基本使用——学习Vue Virtual DOM的最佳入门库

Snabbdom 基本使用 创建项目 打包工具为了方便使用 parcel 创建项目,并安装 parcel # 创建项目目录 md snabbdom-demo # 进入项目目录 cd snabbdom-demo # 创建 package.json npm init -y # 本地安装 parcel npm install parcel-bundler -D 配置 package.json 的 scripts "scripts": { "dev": "parcel index.html --open", "build": "parcel build index.html" } 创建目录结构 │ index.html │ package.json └─src 01-basicusage.js 导入 Snabbdom Snabbdom 文档 看文档的意义 学习任何一个库都要先看文档 通过文档了解库的作用 看文档中提供的示例,自己快速实现一个 demo 通过文档查看 API 的使用 文档地址 https://github.com/snabbdom/snabbdom 当前版本 v2.1.0 # --depth 表示克隆深度, 1 表示只克隆最新的版本. 因为如果项目迭代的版本很多, 克隆会很慢 git clone -b v2.1.0 --depth=1 https://github.com/snabbdom/snabbdom.git 安装 Snabbdom 安装 Snabbdom npm install snabbdom@2.1.0 导入 Snabbdom Snabbdom 的两个核心函数 init 和 h() init() 是一个高阶函数,返回 patch() h() 返回虚拟节点 VNode,这个函数我们在使用 Vue.js 的时候见过 import { init } from 'snabbdom/init' import { h } from 'snabbdom/h' const patch = init([]) 注意:此时运行的话会告诉我们找不到 init / h 模块,因为模块路径并不是 snabbdom/int,这个路径是在 package.json 中的 exports 字段设置的,而我们使用的打包工具不支持 exports 这个字段,webpack 4 也不支持,webpack 5 支持该字段。该字段在导入 snabbdom/init 的时候会补全路径成 snabbdom/build/package/init.js "exports": { "./init": "./build/package/init.js", "./h": "./build/package/h.js", "./helpers/attachto": "./build/package/helpers/attachto.js", "./hooks": "./build/package/hooks.js", "./htmldomapi": "./build/package/htmldomapi.js", "./is": "./build/package/is.js", "./jsx": "./build/package/jsx.js", "./modules/attributes": "./build/package/modules/attributes.js", "./modules/class": "./build/package/modules/class.js", "./modules/dataset": "./build/package/modules/dataset.js", "./modules/eventlisteners": "./build/package/modules/eventlisteners.js", "./modules/hero": "./build/package/modules/hero.js", "./modules/module": "./build/package/modules/module.js", "./modules/props": "./build/package/modules/props.js", "./modules/style": "./build/package/modules/style.js", "./thunk": "./build/package/thunk.js", "./tovnode": "./build/package/tovnode.js", "./vnode": "./build/package/vnode.js" } 如果使用不支持 package.json 的 exports 字段的打包工具,我们应该把模块的路径写全查看安装的 snabbdom 的目录结构 import { h } from 'snabbdom/build/package/h' import { init } from 'snabbdom/build/package/init' import { classModule } from 'snabbdom/build/package/modules/class' 回顾 Vue 中的 render 函数 new Vue({ router, store, render: h => h(App) }).$mount('#app') thunk() 是一种优化策略,可以在处理不可变数据时使用 代码演示 基本使用 import { h } from 'snabbdom/build/package/h' import { init } from 'snabbdom/build/package/init' // 使用 init() 函数创建 patch() // init() 的参数是数组,将来可以传入模块,处理属性/样式/事件等 let patch = init([]) // 使用 h() 函数创建 vnode let vnode = h('div.cls', [ h('h1', 'Hello Snabbdom'), h('p', '这是段落') ]) const app = document.querySelector('#app') // 把 vnode 渲染到空的 DOM 元素(替换) // 会返回新的 vnode let oldVnode = patch(app, vnode) setTimeout(() => { vnode = h('div.cls', [ h('h1', 'Hello World'), h('p', '这是段落') ]) // 把老的视图更新到新的状态 oldVnode = patch(oldVnode, vnode) // h('!') 是创建注释 patch(oldVnode, h('!')) }, 2000) 模块 Snabbdom 的核心库并不能处理 DOM 元素的属性/样式/事件等,如果需要处理的话,可以使用模块 常用模块 官方提供了 6 个模块 attributes设置 DOM 元素的属性,使用 setAttribute() 处理布尔类型的属性 props和 attributes 模块相似,设置 DOM 元素的属性 element[attr] = value 不处理布尔类型的属性 class切换类样式 注意:给元素设置类样式是通过 sel 选择器 dataset设置 data-* 的自定义属性 eventlisteners注册和移除事件 style设置行内样式,支持动画 delayed/remove/destroy 模块使用 模块使用步骤:导入需要的模块 init() 中注册模块 使用 h() 函数创建 VNode 的时候,可以把第二个参数设置为对象,其他参数往后移 代码演示 import { h } from 'snabbdom/build/package/h' import { init } from 'snabbdom/build/package/init' // 导入需要的模块 import { styleModule } from 'snabbdom/build/package/modules/style' import { eventListenersModule } from 'snabbdom/build/package/modules/eventlisteners' // 使用 init() 函数创建 patch() // init() 的参数是数组,将来可以传入模块,处理属性/样式/事件等 let patch = init([ // 注册模块 styleModule, eventListenersModule ]) // 使用 h() 函数创建 vnode let vnode = h('div.cls', { // 设置 DOM 元素的行内样式 style: { color: '#DEDEDE', backgroundColor: '#181A1B' }, // 注册事件 on: { click: clickHandler } }, [ h('h1', 'Hello Snabbdom'), h('p', '这是段落') ]) function clickHandler () { // 此处的 this 指向对应的 vnode console.log(this.elm.innerHTML) }

40
life.caoover 2 years

Snabbdom 源码解析与如何调试(一)

如何学习源码 先宏观了解 带着目标看源码 看源码的过程要不求甚解 调试 参考资料 Snabbdom 的核心 使用 h() 函数创建 JavaScript 对象(VNode)描述真实 DOM init() 设置模块,创建 patch() patch() 比较新旧两个 VNode 把变化的内容更新到真实 DOM 树上 Snabbdom 源码 源码地址: https://github.com/snabbdom/snabbdom src 目录结构 ├── package │   ├── helpers │   │   └── attachto.ts 定义了 vnode.ts 中 AttachData 的数据结构 │   ├── modules │   │   ├── attributes.ts │   │   ├── class.ts │   │   ├── dataset.ts │   │   ├── eventlisteners.ts │   │   ├── hero.ts example 中使用到的自定义钩子 │   │   ├── module.ts 定义了模块中用到的钩子函数 │   │   ├── props.ts │   │   └── style.ts │   ├── h.ts h() 函数,用来创建 VNode │   ├── hooks.ts 所有钩子函数的定义 │   ├── htmldomapi.ts 对 DOM API 的包装 │   ├── init.ts 加载 modules、DOMAPI,返回 patch 函数 │   ├── is.ts 判断数组和原始值的函数 │   ├── jsx-global.ts jsx 的类型声明文件 │   ├── jsx.ts 处理 jsx │   ├── thunk.ts 优化处理,对复杂视图不可变值得优化 │   ├── tovnode.ts DOM 转换成 VNode │   ├── ts-transform-js-extension.cjs │   ├── tsconfig.json ts 的编译配置文件 │   └── vnode.ts 虚拟节点定义 h 函数 h() 函数介绍 在使用 Vue 的时候见过 h() 函数 new Vue({ router, store, render: h => h(App) }).$mount('#app') h() 函数最早见于 hyperscript,使用 JavaScript 创建超文本 Snabbdom 中的 h() 函数不是用来创建超文本,而是创建 VNode 函数重载 概念 参数个数或类型不同的函数 JavaScript 中没有重载的概念 TypeScript 中有重载,不过重载的实现还是通过代码调整参数 重载的示意 function add (a: number, b: number) { console.log(a + b) } function add (a: number, b: number, c: number) { console.log(a + b + c) } add(1, 2) add(1, 2, 3) function add (a: number, b: number) { console.log(a + b) } function add (a: number, b: string) { console.log(a + b) } add(1, 2) add(1, '2') 源码位置:src/package/h.ts // h 函数的重载 export function h (sel: string): VNode export function h (sel: string, data: VNodeData | null): VNode export function h (sel: string, children: VNodeChildren): VNode export function h (sel: string, data: VNodeData | null, children: VNodeChildren): VNode export function h (sel: any, b?: any, c?: any): VNode { var data: VNodeData = {} var children: any var text: any var i: number // 处理参数,实现重载的机制 if (c !== undefined) { // 处理三个参数的情况 // sel、data、children/text if (b !== null) { data = b } if (is.array(c)) { children = c } else if (is.primitive(c)) { text = c } else if (c && c.sel) { children = [c] } } else if (b !== undefined && b !== null) { if (is.array(b)) { children = b } else if (is.primitive(b)) { // 如果 c 是字符串或者数字 text = b } else if (b && b.sel) { // 如果 b 是 VNode children = [b] } else { data = b } } if (children !== undefined) { // 处理 children 中的原始值(string/number) for (i = 0; i < children.length; ++i) { // 如果 child 是 string/number,创建文本节点 if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined) } } if ( sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' && (sel.length === 3 || sel[3] === '.' || sel[3] === '#') ) { // 如果是 svg,添加命名空间 addNS(data, children, sel) } // 返回 VNode return vnode(sel, data, children, text, undefined) }; VNode 一个 VNode 就是一个虚拟节点用来描述一个 DOM 元素,如果这个 VNode 有 children 就是 Virtual DOM 源码位置:src/package/vnode.ts export interface VNode { // 选择器 sel: string | undefined; // 节点数据:属性/样式/事件等 data: VNodeData | undefined; // 子节点,和 text 只能互斥 children: Array<VNode | string> | undefined; // 记录 vnode 对应的真实 DOM elm: Node | undefined; // 节点中的内容,和 children 只能互斥 text: string | undefined; // 优化用 key: Key | undefined; } export function vnode (sel: string | undefined, data: any | undefined, children: Array<VNode | string> | undefined, text: string | undefined, elm: Element | Text | undefined): VNode { const key = data === undefined ? undefined : data.key return { sel, data, children, text, elm, key } } snabbdom patch(oldVnode, newVnode) 打补丁,把新节点中变化的内容渲染到真实 DOM,最后返回新节点作为下一次处理的旧节点 对比新旧 VNode 是否相同节点(节点的 key 和 sel 相同) 如果不是相同节点,删除之前的内容,重新渲染 如果是相同节点,再判断新的 VNode 是否有 text,如果有并且和 oldVnode 的 text 不同,直接更新文本内容 如果新的 VNode 有 children,判断子节点是否有变化,判断子节点的过程使用的就是 diff 算法 diff 过程只进行同层级比较 image-20200102103653779 init 功能:init(modules, domApi),返回 patch() 函数(高阶函数) 为什么要使用高阶函数? 因为 patch() 函数再外部会调用多次,每次调用依赖一些参数,比如:modules/domApi/cbs 通过高阶函数让 init() 内部形成闭包,返回的 patch() 可以访问到 modules/domApi/cbs,而不需要重新创建 init() 在返回 patch() 之前,首先收集了所有模块中的钩子函数存储到 cbs 对象中 源码位置:src/package/init.ts const hooks: Array<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post'] export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) { let i: number let j: number const cbs: ModuleHooks = { create: [], update: [], remove: [], destroy: [], pre: [], post: [] } // 初始化 api const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi // 把传入的所有模块的钩子方法,统一存储到 cbs 对象中 // 最终构建的 cbs 对象的形式 cbs = [ create: [fn1, fn2], update: [], ... ] for (i = 0; i < hooks.length; ++i) { // cbs['create'] = [] cbs[hooks[i]] = [] for (j = 0; j < modules.length; ++j) { // const hook = modules[0]['create'] const hook = modules[j][hooks[i]] if (hook !== undefined) { (cbs[hooks[i]] as any[]).push(hook) } } } …… return function patch (oldVnode: VNode | Element, vnode: VNode): VNode { …… } } patch 功能: 传入新旧 VNode,对比差异,把差异渲染到 DOM 返回新的 VNode,作为下一次 patch() 的 oldVnode 执行过程: 首先执行模块中的钩子函数 pre 如果 oldVnode 和 vnode 相同(key 和 sel 相同)调用 patchVnode(),找节点的差异并更新 DOM 如果 oldVnode 是 DOM 元素把 DOM 元素转换成 oldVnode 调用 createElm() 把 vnode 转换为真实 DOM,记录到 vnode.elm 把刚创建的 DOM 元素插入到 parent 中 移除老节点 触发用户设置的 create 钩子函数 源码位置:src/package/init.ts return function patch (oldVnode: VNode | Element, vnode: VNode): VNode { let i: number, elm: Node, parent: Node // 保存新插入节点的队列,为了触发钩子函数 const insertedVnodeQueue: VNodeQueue = [] // 执行模块的 pre 钩子函数 for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]() // 如果 oldVnode 不是 VNode,创建 VNode 并设置 elm if (!isVnode(oldVnode)) { // 把 DOM 元素转换成空的 VNode oldVnode = emptyNodeAt(oldVnode) } // 如果新旧节点是相同节点(key 和 sel 相同) if (sameVnode(oldVnode, vnode)) { // 找节点的差异并更新 DOM patchVnode(oldVnode, vnode, insertedVnodeQueue) } else { // 如果新旧节点不同,vnode 创建对应的 DOM // 获取当前的 DOM 元素 elm = oldVnode.elm! parent = api.parentNode(elm) as Node // 触发 init/create 钩子函数,创建 DOM createElm(vnode, insertedVnodeQueue) if (parent !== null) { // 如果父节点不为空,把 vnode 对应的 DOM 插入到文档中 api.insertBefore(parent, vnode.elm!, api.nextSibling(elm)) // 移除老节点 removeVnodes(parent, [oldVnode], 0, 0) } } // 执行用户设置的 insert 钩子函数 for (i = 0; i < insertedVnodeQueue.length; ++i) { insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i]) } // 执行模块的 post 钩子函数 for (i = 0; i < cbs.post.length; ++i) cbs.post[i]() return vnode }

28
life.caoover 2 years

Snabbdom 源码解析与如何调试(二)

createElm 功能: createElm(vnode, insertedVnodeQueue),返回创建的 DOM 元素 创建 vnode 对应的 DOM 元素 执行过程: 首先触发用户设置的 init 钩子函数 如果选择器是!,创建评论节点 如果选择器为空,创建文本节点 如果选择器不为空解析选择器,设置标签的 id 和 class 属性 执行模块的 create 钩子函数 如果 vnode 有 children,创建子 vnode 对应的 DOM,追加到 DOM 树 如果 vnode 的 text 值是 string/number,创建文本节点并追击到 DOM 树 执行用户设置的 create 钩子函数 如果有用户设置的 insert 钩子函数,把 vnode 添加到队列中 源码位置:src/package/init.ts function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node { let i: any let data = vnode.data if (data !== undefined) { // 执行用户设置的 init 钩子函数 const init = data.hook?.init if (isDef(init)) { init(vnode) data = vnode.data } } const children = vnode.children const sel = vnode.sel if (sel === '!') { // 如果选择器是!,创建注释节点 if (isUndef(vnode.text)) { vnode.text = '' } vnode.elm = api.createComment(vnode.text!) } else if (sel !== undefined) { // 如果选择器不为空 // 解析选择器 // Parse selector const hashIdx = sel.indexOf('#') const dotIdx = sel.indexOf('.', hashIdx) const hash = hashIdx > 0 ? hashIdx : sel.length const dot = dotIdx > 0 ? dotIdx : sel.length const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel const elm = vnode.elm = isDef(data) && isDef(i = data.ns) ? api.createElementNS(i, tag) : api.createElement(tag) if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot)) if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' ')) // 执行模块的 create 钩子函数 for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode) // 如果 vnode 中有子节点,创建子 vnode 对应的 DOM 元素并追加到 DOM 树上 if (is.array(children)) { for (i = 0; i < children.length; ++i) { const ch = children[i] if (ch != null) { api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue)) } } } else if (is.primitive(vnode.text)) { // 如果 vnode 的 text 值是 string/number,创建文本节点并追加到 DOM 树 api.appendChild(elm, api.createTextNode(vnode.text)) } const hook = vnode.data!.hook if (isDef(hook)) { // 执行用户传入的钩子 create hook.create?.(emptyNode, vnode) if (hook.insert) { // 把 vnode 添加到队列中,为后续执行 insert 钩子做准备 insertedVnodeQueue.push(vnode) } } } else { // 如果选择器为空,创建文本节点 vnode.elm = api.createTextNode(vnode.text!) } // 返回新创建的 DOM return vnode.elm } patchVnode 功能: patchVnode(oldVnode, vnode, insertedVnodeQueue) 对比 oldVnode 和 vnode 的差异,把差异渲染到 DOM 执行过程: 首先执行用户设置的 prepatch 钩子函数 执行 create 钩子函数首先执行模块的 create 钩子函数 然后执行用户设置的 create 钩子函数 如果 vnode.text 未定义如果 oldVnode.children 和 vnode.children 都有值调用 updateChildren() 使用 diff 算法对比子节点,更新子节点 如果 vnode.children 有值,oldVnode.children 无值清空 DOM 元素 调用 addVnodes(),批量添加子节点 如果 oldVnode.children 有值,vnode.children 无值调用 removeVnodes(),批量移除子节点 如果 oldVnode.text 有值清空 DOM 元素的内容 如果设置了 vnode.text 并且和和 oldVnode.text 不等如果老节点有子节点,全部移除 设置 DOM 元素的 textContent 为 vnode.text 最后执行用户设置的 postpatch 钩子函数 源码位置:src/package/init.ts function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) { const hook = vnode.data?.hook // 首先执行用户设置的 prepatch 钩子函数 hook?.prepatch?.(oldVnode, vnode) const elm = vnode.elm = oldVnode.elm! const oldCh = oldVnode.children as VNode[] const ch = vnode.children as VNode[] // 如果新老 vnode 相同返回 if (oldVnode === vnode) return if (vnode.data !== undefined) { // 执行模块的 update 钩子函数 for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode) // 执行用户设置的 update 钩子函数 vnode.data.hook?.update?.(oldVnode, vnode) } // 如果 vnode.text 未定义 if (isUndef(vnode.text)) { // 如果新老节点都有 children if (isDef(oldCh) && isDef(ch)) { // 调用 updateChildren 对比子节点,更新子节点 if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue) } else if (isDef(ch)) { // 如果新节点有 children,老节点没有 children // 如果老节点有text,清空dom 元素的内容 if (isDef(oldVnode.text)) api.setTextContent(elm, '') // 批量添加子节点 addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) } else if (isDef(oldCh)) { // 如果老节点有children,新节点没有children // 批量移除子节点 removeVnodes(elm, oldCh, 0, oldCh.length - 1) } else if (isDef(oldVnode.text)) { // 如果老节点有 text,清空 DOM 元素 api.setTextContent(elm, '') } } else if (oldVnode.text !== vnode.text) { // 如果没有设置 vnode.text if (isDef(oldCh)) { // 如果老节点有 children,移除 removeVnodes(elm, oldCh, 0, oldCh.length - 1) } // 设置 DOM 元素的 textContent 为 vnode.text api.setTextContent(elm, vnode.text!) } // 最后执行用户设置的 postpatch 钩子函数 hook?.postpatch?.(oldVnode, vnode) } updateChildren 功能: diff 算法的核心,对比新旧节点的 children,更新 DOM 执行过程: 要对比两棵树的差异,我们可以取第一棵树的每一个节点依次和第二课树的每一个节点比较,但是这样的时间复杂度为 O(n^3) 在DOM 操作的时候我们很少很少会把一个父节点移动/更新到某一个子节点 因此只需要找同级别的子节点依次比较,然后再找下一级别的节点比较,这样算法的时间复杂度为 O(n) image-20200102103653779.png 在进行同级别节点比较的时候,首先会对新老节点数组的开始和结尾节点设置标记索引,遍历的过程中移动索引 在对开始和结束节点比较的时候,总共有四种情况oldStartVnode / newStartVnode (旧开始节点 / 新开始节点) oldEndVnode / newEndVnode (旧结束节点 / 新结束节点) oldStartVnode / oldEndVnode (旧开始节点 / 新结束节点) oldEndVnode / newStartVnode (旧结束节点 / 新开始节点) image-20200109184608649 开始节点和结束节点比较,这两种情况类似oldStartVnode / newStartVnode (旧开始节点 / 新开始节点) oldEndVnode / newEndVnode (旧结束节点 / 新结束节点) 如果 oldStartVnode 和 newStartVnode 是 sameVnode (key 和 sel 相同)调用 patchVnode() 对比和更新节点 把旧开始和新开始索引往后移动 oldStartIdx++ / oldEndIdx++ image-20200103121812840 oldStartVnode / newEndVnode (旧开始节点 / 新结束节点) 相同 - 调用 patchVnode() 对比和更新节点 把 oldStartVnode 对应的 DOM 元素,移动到右边 - 更新索引 image-20200103125428541 oldEndVnode / newStartVnode (旧结束节点 / 新开始节点) 相同调用 patchVnode() 对比和更新节点把 oldEndVnode 对应的 DOM 元素,移动到左边 更新索引 image-20200103125735048 如果不是以上四种情况遍历新节点,使用 newStartNode 的 key 在老节点数组中找相同节点 如果没有找到,说明 newStartNode 是新节点创建新节点对应的 DOM 元素,插入到 DOM 树中 如果找到了判断新节点和找到的老节点的 sel 选择器是否相同 如果不相同,说明节点被修改了重新创建对应的 DOM 元素,插入到 DOM 树中 如果相同,把 elmToMove 对应的 DOM 元素,移动到左边 image-20200109184822439 循环结束当老节点的所有子节点先遍历完 (oldStartIdx > oldEndIdx),循环结束 新节点的所有子节点先遍历完 (newStartIdx > newEndIdx),循环结束 如果老节点的数组先遍历完(oldStartIdx > oldEndIdx),说明新节点有剩余,把剩余节点批量插入到右边 image-20200103150918335 ​ 如果新节点的数组先遍历完(newStartIdx > newEndIdx),说明老节点有剩余,把剩余节点批量删除 image-20200109194751093 源码位置:src/package/init.ts function updateChildren (parentElm: Node, oldCh: VNode[], newCh: VNode[], insertedVnodeQueue: VNodeQueue) { let oldStartIdx = 0 let newStartIdx = 0 let oldEndIdx = oldCh.length - 1 let oldStartVnode = oldCh[0] let oldEndVnode = oldCh[oldEndIdx] let newEndIdx = newCh.length - 1 let newStartVnode = newCh[0] let newEndVnode = newCh[newEndIdx] let oldKeyToIdx: KeyToIndexMap | undefined let idxInOld: number let elmToMove: VNode let before: any while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 索引变化后,可能会把节点设置为空 if (oldStartVnode == null) { // 节点为空移动索引 oldStartVnode = oldCh[++oldStartIdx] // Vnode might have been moved left } else if (oldEndVnode == null) { oldEndVnode = oldCh[--oldEndIdx] } else if (newStartVnode == null) { newStartVnode = newCh[++newStartIdx] } else if (newEndVnode == null) { newEndVnode = newCh[--newEndIdx] // 比较开始和结束节点的四种情况 } else if (sameVnode(oldStartVnode, newStartVnode)) { // 1. 比较老开始节点和新的开始节点 patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue) oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] } else if (sameVnode(oldEndVnode, newEndVnode)) { // 2. 比较老结束节点和新的结束节点 patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue) oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right // 3. 比较老开始节点和新的结束节点 patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue) api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!)) oldStartVnode = oldCh[++oldStartIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left // 4. 比较老结束节点和新的开始节点 patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue) api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!) oldEndVnode = oldCh[--oldEndIdx] newStartVnode = newCh[++newStartIdx] } else { // 开始节点和结束节点都不相同 // 使用 newStartNode 的 key 再老节点数组中找相同节点 // 先设置记录 key 和 index 的对象 if (oldKeyToIdx === undefined) { oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) } // 遍历 newStartVnode, 从老的节点中找相同 key 的 oldVnode 的索引 idxInOld = oldKeyToIdx[newStartVnode.key as string] // 如果是新的vnode if (isUndef(idxInOld)) { // New element // 如果没找到,newStartNode 是新节点 // 创建元素插入 DOM 树 api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!) } else { // 如果找到相同 key 相同的老节点,记录到 elmToMove 遍历 elmToMove = oldCh[idxInOld] if (elmToMove.sel !== newStartVnode.sel) { // 如果新旧节点的选择器不同 // 创建新开始节点对应的 DOM 元素,插入到 DOM 树中 api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!) } else { // 如果相同,patchVnode() // 把 elmToMove 对应的 DOM 元素,移动到左边 patchVnode(elmToMove, newStartVnode, insertedVnodeQueue) oldCh[idxInOld] = undefined as any api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!) } } // 重新给 newStartVnode 赋值,指向下一个新节点 newStartVnode = newCh[++newStartIdx] } } // 循环结束,老节点数组先遍历完成或者新节点数组先遍历完成 if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) { if (oldStartIdx > oldEndIdx) { // 如果老节点数组先遍历完成,说明有新的节点剩余 // 把剩余的新节点都插入到右边 before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) } else { // 如果新节点数组先遍历完成,说明老节点有剩余 // 批量删除老节点 removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx) } } } 调试 updateChildren <ul> <li>首页</li> <li>微博</li> <li>视频</li> </ul> <ul> <li>首页</li> <li>视频</li> <li>微博</li> </ul> image-20200112120036948 Snabbdom 源码解析与如何调试介绍到此

53
life.caoover 2 years

吐血整理react快速入门精华(一)

1. React 介绍 React 是一个用于构建用户界面的 JavaScript 库,它只负责应用的视图层,帮助开发人员构建快速且交互式的 web 应用程序。 React 使用组件的方式构建用户界面。 2. JSX 语法 在 React 中使用 JSX 语法描述用户界面,它是一种 JavaScript 语法扩展。 在 React 代码执行之前,Babel 会将 JSX 语法转换为标准的 JavaScript API。 JSX 语法就是一种语法糖,让开发人员使用更加舒服的代码构建用户界面。 2.1 在 JSX 中使用表达式 const user = { firstName: 'Harper', lastName: 'Perez' } function formatName(user) { return user.firstName + ' ' + user.lastName; } const element = <h1>Hello, {formatName(user)}!</h1>; JSX 本身其实也是一种表达式,将它赋值给变量,当作参数传入,作为返回值都可以。 function getGreeting(user) { if (user) { return <h1>Hello, {formatName(user)}!</h1>; } return <h1>Hello, Stranger.</h1>; } 2.2 属性 如果属性值为字符串类型,需要加引号,属性名称推荐采用驼峰式命名法。 const element = <div greeting="hello"></div>; 如果属性值为JavaScript表达式,属性值外面加大括号。 const element = <img src={user.avatarUrl} />; // 注意大括号外面不能加引号,JSX 会将引号当中的内容识别为字符串而不是表达式 2.3 JSX 单标记必须闭合 如果 JSX 是单标记,必须闭合,否则报错。 const element = <img src={user.avatarUrl} /> const element = <input type="text"/> 2.4 className 为 JSX 标记添加类名需要使用 className,而不是class。 const element = <img src={user.avatarUrl} className="rounded"/>; 2.5 JSX 自动展开数组 const ary = [<p>哈哈</p>, <p>呵呵</p>, <p>嘿嘿</p>]; const element = ( <div>{ary}</div> ); // 解析后 /* <div> <p>哈哈</p> <p>呵呵</p> <p>嘿嘿</p> </div> */ 2.6 三元运算 { boolean ? <div>Hello React</div> : null } { boolean && <div>Hello React</div> } 2.7 循环 const persons = [{ id: 1, name: '张三', age: 20 }, { id: 2, name: '李四', age: 15 }, { id: 3, name: '王五', age: 22 }] <ul> { persons.map(person => <li key={person.id}> {person.name} {person.age} </li>) } </ul> 2.8 事件 {/* 第一个参数即是事件对象 不需传递 */} <button onClick={this.eventHandler}>按钮</button> {/* 需要传递事件对象 */} <button onClick={e=>this.eventHandler('arg',e)}>按钮</button> {/* 最后一个参数即是事件对象 不需传递 */} <button onClick={this.eventHandler.bind(null, 'arg')}>按钮</button> constructor () { this.eventHandler = this.eventHandler.bind(this) } eventHandler () {} <button onClick={this.eventHandler}>按钮</button> 2.9 样式 2.9.1 行内样式 class App extends Component { render() { const style = {width: 200, height: 200, backgroundColor: 'red'}; return <div style={style}></div> } } 2.9.2 外链样式 // Button.js import styles from './Button.module.css'; class Button extends Component { render() { return <button className={styles.error}>Error Button</button>; } } 2.9.3 全局样式 import './styles.css' 2.10 ref 属性 2.10.1 createRef class Input extends Component { constructor() { super() this.inputRef = React.createRef() } render() { return ( <div> <input type="text" ref={this.inputRef} /> <button onClick={() => console.log(this.inputRef.current)}> button </button> </div> ) } } 2.10.2 函数参数 class Input extends Component { render() { return ( <div> <input type="text" ref={input => (this.input = input)} /> <button onClick={() => console.log(this.input)}>button</button> </div> ) } } 2.10.3 ref 字符串 不推荐使用,在严格模式下报错。 class Input extends Component { render() { return ( <div> <input type="text" ref="username" /> <button onClick={() => console.log(this.refs.username)}>button</button> </div> ) } } 2.10.4 获取组件实例 点击按钮让 input 文本框获取焦点。 input 文本框以及让文本框获取焦点的方法定义在 Input 组件中,在 App 组件中引入 Input 组件,按钮定义在 App 组件中。 // Input.js class Input extends Component { constructor() { super() this.inputRef = React.createRef() this.focusInput = this.focusInput.bind(this) } focusInput() { this.inputRef.current.focus() } render() { return ( <div> <input type="text" ref={this.inputRef} /> </div> ) } } // App.js class App extends Component { constructor() { super() this.InputComponentRef = React.createRef() } render() { return ( <div className="App"> <Input ref={this.InputComponentRef} /> <button onClick={() => this.InputComponentRef.current.focusInput()}>button</button> </div> ) } 3. 组件 3.1 什么是组件 React 是基于组件的方式进行用户界面开发的. 组件可以理解为对页面中某一块区域的封装。 3.2 创建组件 3.2.1 创建类组件 import React, { Component } from 'react'; class App extends Component { render () { return <div>Hello, 我是类组件</div> } } 3.2.2 创建函数组件 const Person = () => { return <div>Hello, 我是函数型组件</div>; } 注意事项 组件名称首字母必须大写,用以区分组件和普通标签。 jsx语法外层必须有一个根元素 3.3 组件 props 3.3.1 props 传递数据 在调用组件时可以向组件内部传递数据,在组件中可以通过 props 对象获取外部传递进来的数据。 <Person name="乔治" age="20"/> <Person name="玛丽" age="10"/> // 类组件 class Person extends Component { render() { return ( <div> <h3>姓名:{this.props.name}</h3> <h4>年龄:{this.props.age}</h4> </div> ); } } // 函数组件 const Person = props => { return ( <div> <h3>姓名:{props.name}</h3> <h4>年龄:{props.age}</h4> </div> ); } 注意: props 对象中存储的数据是只读的,不能在组件内部被修改。 当 props 数据源中的数据被修改后,组件中的接收到的 props 数据会被同步更新。( 数据驱动DOM ) 3.3.2 设置 props 默认值 class App extends Component { static defaultProps = {} } function ThemedButton(props) { } ThemedButton.defaultProps = { theme: "secondary", label: "Button Text" }; 3.3.3 组件 children 通过 props.children 属性可以获取到在调用组件时填充到组件标签内部的内容。 <Person>组件内部的内容</Person> const Person = (props) => { return ( <div>{props.children}</div> ); } 3.3.4 单向数据流 在React中, 关于数据流动有一条原则, 就是单向数据流动, 自顶向下, 从父组件到子组件. 单向数据流特性要求我们共享数据要放置在上层组件中. 子组件通过调用父组件传递过来的方法更改数据. 当数据发生更改时, React会重新渲染组件树. 单向数据流使组件之间的数据流动变得可预测. 使得定位程序错误变得简单.

411
life.caoover 2 years

吐血整理react快速入门精华(二)

3.4 类组件状态 state 3.4.1 定义组件状态 类组件除了能够从外部 (props) 接收状态数据以外还可以拥有自己的状态 (state),此状态在组件内部可以被更新,状态更新 DOM 更新。 组件内部的状态数据被存储在组件类中的 state 属性中,state 属性值为对象类型,属性名称固定不可更改。 class App extends Component { constructor () { super() this.state = { person: { name: '张三', age: 20 }, } } render () { return ( <div> {this.state.person.name} {this.state.person.age} </div> ); } } 3.4.2 更改组件状态 state 状态对象中的数据不可直接更改,如果直接更改 DOM 不会被更新,要更改 state 状态数据需要使用 setState方法。 class App extends Component { constructor () { this.state = { person: { name: '张三', age: 20 }, } this.changePerson = this.changePerson.bind(this) } changePerson () { this.setState({ person: { name: '李四', age: 15 } }) } render() { return ( <div> {this.state.person.name} {this.state.person.age} <button onClick={this.changePerson}>按钮</button> </div> ); } } 3.4.3 双向数据绑定 双向数据绑定是指,组件类中更新了状态,DOM 状态同步更新,DOM 更改了状态,组件类中同步更新。组件 <=> 视图。 要实现双向数据绑定需要用到表单元素和 state 状态对象。 class App extends Component { constructor () { this.state = { name: "张三" } this.nameChanged = this.nameChanged.bind(this) } nameChanged (event) { this.setState({name: event.target.value}); } render() { return ( <div> <div>{this.state.name}</div> <Person name={this.state.name} changed={this.nameChanged}/> </div> ) } } const Person = props => { return <input type="text" value={props.name} onChange={props.changed}/>; } 3.5 类组件生命周期函数 在组件完成更新之前需要做某种逻辑或者计算,就需要用到快照 componentDidUpdate(prevProps, prevState, snapshot) {} getSnapshotBeforeUpdate 方法会在组件完成更新之前执行,用于执行某种逻辑或计算,返回值可以在 componentDidUpdate 方法中的第三个参数中获取,就是说在组件更新之后可以拿到这个值再去做其他事情。 getSnapshotBeforeUpdate(prevProps, prevState) { return 'snapshot' } 3.6 Context 通过 Context 可以跨层级传递数据 // userContext.js import React from "react" const userContext = React.createContext("default value") const UserProvider = userContext.Provider const UserConsumer = userContext.Consumer export { UserProvider, UserConsumer } // App.js import { UserProvider } from "./userContext" class App extends Component { render() { return ( <UserProvider value="Hello React Context"> <A /> </UserProvider> ) } } // C.js import { UserConsumer } from "./userContext" export class C extends Component { render() { return ( <div> <UserConsumer> {username => { return <div>{username}</div> }} </UserConsumer> </div> ) } } context 的另一种用法 // userContext.js export default userContext // C.js import userContext from "./userContext" export class C extends Component { static contextType = userContext render() { return ( <div> {this.context} </div> ) } } 4. 表单 4.1 受控表单 表单控件中的值由组件的 state 对象来管理,state对象中存储的值和表单控件中的值时同步状态的 class App extends Component { constructor () { this.state = { username: "" } this.nameChanged = this.nameChanged.bind(this) } nameChanged (e) { this.setState({username: e.target.value}) } render() { return ( <form> <p>{this.state.username}</p> <input type="text" value={this.state.username} onChange={this.nameChanged}/> </form> ) } } 4.2 非受控表单 表单元素的值由 DOM 元素本身管理。 class App extends Component { constructor () { this.onSubmit = this.onSubmit.bind(this) } onSubmit(e) { console.log(this.username.value) e.preventDefault(); } render( <form onSubmit={this.onSubmit}> <input type="text" ref={username => this.username = username}/> </form> ) } 5. 路由 url地址与组件之间的对应关系,访问不同的url地址显示不同的组件。 下载:npm install react-router-dom 5.1.1 路由基本使用 // App.js import React from 'react'; import { BrowserRouter as Router, Route, Link } from 'react-router-dom'; function Index() { return <div>首页</div>; } function News() { return <div>新闻</div>; } function App() { return ( <Router> <div> <Link to="/index">首页</Link> <Link to="/news">新闻</Link> </div> <div> <Route path="/index" component={Index}/> <Route path="/news" component={News}/> </div> </Router> ); } 5.1.2 路由嵌套 function News(props) { return ( <div> <div> <Link to={`${props.match.url}/company`}>公司新闻</Link> <Link to={`${props.match.url}/industry`}>行业新闻</Link> </div> <div> <Route path={`${props.match.path}/company`} component={CompanyNews} /> <Route path={`${props.match.path}/industry`} component={IndustryNews}/> </div> </div> ); } function CompanyNews() { return <div>公司新闻</div> } function IndustryNews() { return <div>行业新闻</div> } 5.1.3 路由传参 import url from 'url'; class News extends Component { constructor(props) { super(props); this.state = { list: [{ id: 1, title: '新闻1' }, { id: 2, title: '新闻2' }] } } render() { return ( <div> <div>新闻列表组件</div> <ul> this.state.list.map((item, index) => { return ( <li key={index}> <Link to={`/detail?id=${item.id}`}>{item.title}</Link> </li> ); }) </ul> </div> ); } } class Detail extends Component { constructor(props) { super(props); } const { query } = url.parse(this.props.location.search, true); console.log(query); // {id: 1} render() { return <div>新闻详情</div> } } 5.1.4 路由重定向 import { Redirect } from 'react-router-dom'; class Login extends Component { render() { if (this.state.isLogin) { return <Redirect to="/"/> } } }

382
life.caoalmost 3 years

一行配置构建速度提升,Webpack5持久化缓存

接近 10w 行代码项目开启后二次构建效果如上,第一次还无缓存需要41秒,第二次有缓存后构建只需2秒(不计算安装依赖时间,只计算同一机器上的构建时间)! Webpack5 持久化缓存 Webpack5 的核心新功能之一就是带来了开箱即用的持久化缓存,前一段时间在组内部分前端项目,缓存了npm包与编译时缓存,让项目构建速度,效率倍增。改问题的时候让产品发出了下面的感慨,特写一篇文章安利一下 Webpack 持久化缓存这个新功能。 开始使用 下面这行 Webpack 配置就是构建速度倍增的秘密,一行配置,让你也成为让产品测试的那个仔。 //Webpack配置 cache:{ type: 'fileSystem' } 当然,在实际使用过程中,不建议使用这么简单粗暴的策略(原谅我的标题党),下面应该才是启用持久性缓存的经典配置: // Webpack 配置module.exports = { cache: { type: 'fileSystem', buildDependencies: { config: [__filename]//下文有详细解读 } }} 缓存失效策略(重点) 既然这项功能对构建性能有优化,为何不默认开启呢?这是因为 Webpack 的开发者优先考虑到了安全性,持久化缓存虽然在 95% 的情况下能显著提升构建性能,但在 5%的情况下会破坏应用程序/工作流/构建,需要使用者对缓存策略有一定的了解才能正确使用(怕你翻车)。 其实就是 Webpack 无法在默认开启的情况下确定何时缓存不再有效,并停止使用缓存,默认开启有一定的风险会导致构建出错。 一个例子: 当你修改项目的一个源码文件magic.js 时,Webpack 当然必须使 magic.js的缓存失效。 构建需要再次处理文件,即运行 babel,typescript,whatever,解析文件并再次并再次运行代码生成。 然后,Webpack可能也会使引用到该文件的源码文件的缓存失效,并且从包含的模块中重新构建此文件 为此,Webpack 需要跟踪每个模块的fileDependencies contextDependencies和missingDependencies,并创建一个文件系统快照,用这个快照与实际文件系统进行比较,当检测到差异时,将为该模块触发重新构建,然后,对于构建中的缓存,Webpack会存储一个etag,只有当它匹配时,才会使用此etag,这些都是开箱即用,即 Webpack 可以闭环处理的部分。 但是分析以下这些情况: npm 升级加载器或插件时 更改配置时 更改在配置中读取的文件时 npm 升级在配置中在配置中使用的依赖项时 向构建脚本传递不同的命令行参数时 有自定义构建脚本并更改它时 Webpack 无法开箱即用地处理所有这些可能导致缓存失效的情况,这样就可能会导致构建出问题。这就是为什么官方选择了使持久性缓存成为一个可选择的功能。而为了处理这些情况,官方还新增了许多相关的配置项。 更多核心配置 cache.buildDependencies 用于允许指定构建过程的代码依赖项,Webpack 负责解析和跟踪指定值的依赖项。 有两种可能的值类型:文件和目录。目录必须以斜杠结尾。其他所有内容都被解析为文件。 对于目录,Webpack将分析最近的package.json以获取依赖项。 对于文件,Webpack将查看 Node.js 模块缓存以找到依赖项。 示例:构建通常依赖于Webpack本身的lib文件夹。可以这样指定: cache.buildDependencies: { defaultWebpack: ["Webpack/lib/"]} 但像 Webpack/lib或 Webpack 的依赖项(如watchpack,enhanced-resolved等)以及 node_modules 里的依赖包(简单策略,没有 hash,使用package.json中的version和name作为标识)其实已经是默认值,所以不必指定。 但是构建通常也依赖于配置文件,所以官方推荐的配置如下: // Webpack 配置module.exports = { cache: { type: 'fileSystem', buildDependencies: { config: [__filename] } }} cache.version 当构建的某些依赖项不能表示为对文件的引用,例如从数据库读取的值,环境变量或在命令行上传递的值。 对于这些值,就要用到这个配置了。 示例:配置读取环境变量GIT_REV并使用DefinePlugin将此值嵌入到包中。这使GIT_REV成为构建的依赖项。可以这样指定: cache: { version: `${process.env.GIT_REV}`} cache.name 在某些情况下,依赖项会在多个不同的值之间切换,每次值更改都使持久性缓存失效将是浪费的。 对于这些值,就可以用到cache.name了。 示例:配置使用--env.target mobile|desktop参数为移动用户或桌面用户创建构建。可以这样指定: cache: { name: `${env.target}`} 更多配置 更多配置与详解,请参考以下文档: https://webpack.js.org/configuration/cache/#cache https://github.com/webpack/changelog-v5/blob/master/guides/persistent-caching.md 核心插件 不同于Vite的按需处理,Wepback 的处理流程,在打包项目时需要加载并编译所有代码,编译过程中文件解析、转译都会花费大量时间。在少量更改代码时冷启动也是需要进行全量编译,导致性能与Vite相差甚远。大致流程如下: 构建模块依赖图:需要读取文件 -> loader 处理 -> 解析代码,从而得到模块描述Module对象。 代码生成:需要逐个完成模块代码转译操作,得到代码生成结果CodeGenerationResult对象。 资源整合生成产物:将模块编译结果整合成资源并输出资源描述对象Source。 在开启缓存时,Wepback 通过内置的Cache实例(cache 的 type 为 memory,使用 MemoryCachePlugin。cache 的 type 为 filesystem,使用 MemoryWithGCCachePlugin)为上述三种对象实例提供缓存能力,从而减少不必要的编译开支,从而达到加速构建的目的。 更多详细原理安利一篇好文章:超详细讲解 Webpack5 缓存实现原理,在此不详细展开。 总结 Webpack5带来了开箱即用的持久化缓存,但因为有一定的风险,并没有默认开启,发现很多小伙伴升级之后并没有用上这项好用的新特性,所以写了这篇文章简单分享下使用心得~希望这篇文章能帮助你更好地理解和使用这个功能(当然,不考虑兼容性还是赶紧上 Vite吧)!

152
life.caoalmost 3 years

低代码之拖拽排序实现思路-徒手开

效果演示 前沿 低代码平台最常见的功能就是拖拽,不同的低代码产品拥有不同的拖拽实现。比如大屏低代码,组件可以自由拖拽,依托定位来实现组件的位置移动;中后台的低代码略有不同,它的布局依托元素本身特性,呈瀑布流形式,它不能自由拖拽,但能调整组件顺序。 下面以中后台为例,讲解一下拖、放、排序、嵌套等功能在低代码中的实现。 移动和拖放原理 移动 对于目标元素,在同一个画布中,从A点移动到B点,使用 mousemove 即可实现,它并非实际意义上的拖拽。 位置计算:鼠标移动坐标 - (鼠标按下坐标 - 目标元素偏移)= 元素实际坐标 拖放 把目标元素从A容器放置到B容器的过程称之为拖放,包含一个拖和一个放两个动作。 过程解析:给元素添加draggable=true即可实现元素拖拽效果,右侧盒子是不能直接接收一个元素对象的,当前需要使用dataTransfer在拖和放之间传递数据,右侧盒子获取到数据后,动态生成目标元素进行DOM渲染。 关键代码: <div id="drag" draggable="true" ondragstart="drag(event)"></div><div class="box" ondrop="drop(event)" ondragover="event.preventDefault();"></div><script> // 拖拽时,保存ID function drag(ev) { ev.dataTransfer.setData('id', ev.target.id); } // 放置时,获取ID,并动态渲染 function drop(ev) { var data = ev.dataTransfer.getData('id'); ev.target.appendChild(document.getElementById(data)); }</script> 低代码拖放 低代码开发为了更加便捷采用了react-dnd插件,核心的API依然是一个拖(useDrag)、一个放(useDrop)。 左侧菜单为拖拽组件,中间画布为放置区域,拖放的过程实际上就是数据传递并动态渲染的过程。 一、动态定义拖拽组件 左侧菜单通常只需要把组件图标和组件名称遍历出来即可,通过数组循环遍历更加简单。 export default [ { type: 'form', title: '表单项', data: [ { icon: '', name: '文本框', type: 'Input', }, { icon: '', name: '按钮', type: 'Button', }, { icon: '', name: '多行文本框', type: 'TextArea', } }] 二、动态生成拖拽组件 封装拖拽目标,外层容器直接遍历即可,此处只渲染了名称。 import { IDragTarget } from '@/packages/types/index';import { DragSourceMonitor, useDrag } from 'react-dnd';/** * 拖拽目标 * @param props 拖拽对象属性值 * @returns 返回可拖拽组件对象 */const DragMenuItem = (props: IDragTarget) => { const [{ isDragging }, drag] = useDrag( () => ({ type: 'MENU_ITEM', item: { type: props.type, }, collect: (monitor: DragSourceMonitor) => { return { isDragging: monitor.isDragging(), }; }, }), [], ); return ( <div className={styles.menuItem} style={{ color: isDragging ? '#f16622' : '#000', }} ref={drag} > {props.name} </div> );};export default DragMenuItem; type:用来设置接收的类型,画布中根据此type才能接收拖拽时传递的数据。 item:拖拽时携带数据。 collect:收集函数,根据monitor可获取当前拖拽目标的实时状态。 三、画布接收组件   使用自定义 Hook useDrop来绑定画布的接收区域。通过accept属性来设置接收的数据类型。 // 拖拽接收 const [collectedProps, drop] = useDrop({ accept: 'MENU_ITEM', drop(item: IDragTargetItem, monitor: any) { // 获取拖拽的组件类型 const type = item.type; // ... } }); return <div ref={drop} className="canvas"></div> 四、画布组件渲染 左侧菜单拖拽组件时,设置了item参数,画布drop方法会接收该参数,我们只需要知道拖拽的组件类型即可,比如是:Input、Button还是Select,根据类型,动态获取该物料组件,从而进行动态渲染。 import * as Components from '@/packages/index';// 动态获取组件const Component: any = Components[item.type as keyof typeof Components];return <Component key={item.id} componentid={item.id} componenttype={item.type} config={item.config} events={item.events} api={item.api}/> 以上就是组件拖放过程,实际上只是数据传递过程,并非直接把组件拖拽上去,根据数据再去物料库中查找物料,从而渲染到画布中。组件拖放到画布中后,还需要保存到全局状态管理中,以备后面使用。 拖拽排序 画布中的组件要支持排序功能,画布中的组件最终保存在数组中,实际上就是数组的排序功能。想要实现组件顺序调整,需要设置画布中的每个组件既是拖拽组件,又是放置区域,此时大大增加了复杂度。 画布中的组件通过动态渲染生成的,此处就需要动态绑定 drag 和 drop 对象,组件的渲染实际上发生在单独的渲染引擎组件中,画布本身只需要调用渲染引擎即可,假设渲染引擎为:MarsRender,代码参考: const [{ isDragging }, drag] = useDrag(() => { return { type: 'Component', item: { parentId: item.parentId, draggedId: item.id, }, collect: (monitor) => ({ isDragging: monitor.isDragging(), }), }; }); // 拖拽接收 const [collectedProps, drop] = useDrop({ accept: 'Component', hover({ parentId, draggedId }: any) { if (!ref.current) { return; } // 如果拖拽的组件和悬浮的组件是同一个则返回。 if (draggedId === item.id || parentId === item.id) return; moveCard(draggedId, item.id); }, }); // 动态获取组件 const Component: any = Components[item.type as keyof typeof Components]; return <Component ref={drag(drop(ref))} key={item.id} className={['mars-component', isDragging ? 'dragging' : '']} id={item.id} componentid={item.id} componenttype={item.type} config={item.config} events={item.events} api={item.api}/> 通过官方提供的drag(drop(ref))同时给元素添加既能拖拽,又能拖放的功能,这里从新定义一个新的type,专门处理画布中组件之间的拖放操作。 当组件A悬浮到组件B上面时,递归查找,把A和B对象做互换即可。 // 组件排序moveElements(state, { payload }) { const { draggedId, overId } = payload; function deepFind(list: ComponentType[]) { return list.map((item, index) => { if (item.id == draggedId) { return state.elementsMap[overId]; } else if (item.id === overId) { return state.elementsMap[draggedId]; } else if (item.elements?.length) { item.elements = deepFind(item.elements); } return item; }); } state.elements = deepFind(state.elements);} 为了更加便捷获取元素对象,我们提前使用Map结构存储了一份组件数据,便于组件查找和赋值。数组当中依然需要保存一份完整的组件列表,用于渲染引擎遍历渲染画布。 如果要做的更加细致, 组件嵌套拖放 组件嵌套实际上只需要开发一个父容器组件,保留一个插槽即可,同时给该容器组件绑定drop对象,能够接收拖拽目标。 const [collectedProps, drop] = useDrop<IDragTargetItem>({ accept: ['MENU_ITEM'], drop(item: IDragTargetItem, monitor: any) { const type = item.type; // .... }, }); return ( <div ref={ref}> <Form form={form} className={styles.searchForm} layout="inline" style={config.style} {...props}> <div className={styles.formWrap} ref={drop}> { elements?.length ? <MarsRender elements={elements || []} moveCard={moveCard} /> : <div className={styles.slots}>拖拽组件到这里</div>} </div> <div className={styles.formAction}> <Button type="primary" style={{ marginRight: 10 }} htmlType="submit"> 查询 </Button> <Button type="default" style={{ backgroundColor: '#fff' }} onClick={handleReset}> 重置 </Button> </div> </Form> </div> ); 容器组件绑定drop,当拖拽左侧菜单组件到该容器组件内时,同样接收该组件类型,动态获取该组件,在插槽的位置进行动态渲染。 本次记录一下中后台低代码中,物料的拖拽、排序和嵌套功能,以及核心的开发思路。

125
life.caoalmost 3 years

如何更好的使用React中context

Context 提供了一个无需为每层组件手动添加 props,就能在组件树间进行数据传递的方法。 基础使用context 通过react官方对context的定义,我们了解context为我们提供了一种,当需要在组件中多层传递我们props时,可以避免每一层都向下传递同样的props。像下面 像上图中的组件结构,或许需要如下代码示例:(不使用context时) // App const [query, setQuery] = useState({ name: "", team: "", age: undefined, score: undefined }) const handleChange = (val) => { setQuery({...query, ...val}) } // FormContainer <FormContainer value={query} onChange={handleChange} /> // SearchForm <SeachForm value={value} onChange={onChange} /> // AgeSelect ScoreSelect <AgeSelect value={value.age} onChange={val => onChange({ age: val})} /> <TableContainer query={query} /> // DownloadButton <DownloadButton query={query} /> <PlayerTable query={query} /> 可以看的出,同样的东西传递了很多层,写法既繁琐也不利于后期维护。 当使用了context时,我们则不必每一层传递props,而是通过context直接在对应的组件内直接获取和修改即可。 示例:(使用context) const initState: QueryState = { name: "", team: "", age: undefined, score: undefined, }; function App() { const state = useState(initState); return ( <AppContext.Provider value={state}> <AppContainer /> </AppContext.Provider> ); } // 这样子我们可以通过useContext, 直接获取和修改我们需要的 // SearchForm const [query, setQuery] = useContext(AppContext); // AgetSelect const [query, setQuery] = useContext(AppContext); <Select value={query.age} onChange={val => { setQuery({ age: val }) }} /> context重复渲染问题 上面context的使用减少了我们传递props的繁琐,但是会引入一个不必要的重复渲染问题。 当我们通过context修改了App组件中的state时,整个组件树自上而下都会重新渲染,也就是说很多组件没有依赖到我们context中的state,但是在我们修改context的值时,却也引起了这些组件的render。 我们可以通过react devtools高亮,来观察重新渲染的组件,如下图: 可以看出我修改name的值时,相应的会修改context中state,但是那些没有依赖的组件 (图1中未标红的组件)也发生了重新渲染,这个是一个性能牺牲,我们不答应。 context更好的使用 要解决context重复渲染问题,也就是当修改context的值时,我们只要rerender那些依赖context的组件即可,不需要从顶层的整个组件树都渲染一遍。 也就是说我们需要一个数据源,然后组件可以订阅数据源的变化,数据源变化时,我们通知订阅的组件变化,触发组件rerender即可。 所以说我们可以简单实现一个发布订阅模型即可。 export type Listener<T> = (state: T) => void; export type Store<T> = { setState: (partial: Partial<T>) => void; getState: () => T; subscribe: (listener: Listener<T>) => () => void; }; export function createStore<T>(initState: T): Store<T> { let state = initState; const listeners = new Set<Listener<T>>(); const setState = (partial: Partial<T>) => { state = { ...state, ...partial }; listeners.forEach((listener) => { listener(state) }); }; const getState = () => state; const subscribe = (listener: Listener<T>) => { listeners.add(listener); return () => listeners.delete(listener); }; return { getState, setState, subscribe, }; } 上面就是一个简单的发布订阅的实现,然后接下来我们需要把它应用到react的应用中,思考:首先我们可以通过context将这个初始化store传递下去,在每个需要的组件中,我们可以通过(getState)获取我们需要的数据和通过(setState)来修改这个数据,然后通过(subscribe)来订阅这个store的数据源变化,然后当数据源变化时各个订阅组件来rerender即可。 具体实现如下: 初始化store,作为context传递 export type QueryState = { name?: string; team?: string; age?: string; score?: string; }; export const AppContext = createContext<Store<QueryState> | null>(null); const initState: QueryState = { name: "", team: "", age: undefined, score: undefined, }; const store = createStore(initState); function App() { return ( <AppContext.Provider value={store}> <AppContainer /> </AppContext.Provider> ); } 组件中添加订阅,由于组件订阅的逻辑基本一致,那我们可以定义一个hook来实现这部分逻辑 const useAppStore = (): [QueryState, (p: Partial<QueryState>) => void] => { const storeCtx = useContext(AppContext)!; const [state, setState] = useState(storeCtx.getState()); // add subscribe useEffect(() => { const unsubscribe = storeCtx.subscribe((s: QueryState) => { setState(s); }); return () => unsubscribe(); }, []); // 这里标红的代码,如果在react-18中 // 我们可以使用新的api useSyncExternalStore // useSyncExternalStore(store.subscribe, store.getState); // https://zh-hans.reactjs.org/docs/hooks-reference.html#usesyncexternalstore return [state, storeCtx.setState]; }; 组件获取或者修改 const DownloadButton = () => { const [query, setQuery] = useAppStore(); const { run, loading } = useRequest( () => { return downloadPlayers({ name: query.name, team: query.team, age: query.age, score: query.score, }); }, { refreshDeps: [query], manual: true, } ); return ( <Button type="primary" loading={loading} onClick={run}> 下载 </Button> ); }; function SearchForm() { const [query, setQuery] = useAppStore(); ......省略 } 最后我们查看经过优化后,我们的应用渲染的效果。 可以通过高亮看到,差不多已经达到了我们预期的思考,只有依赖数据源的组件发生了渲染,其他的依旧如初,很好但是还可以更好~ One more thing... 在我们的demo中AgeSelect和ScoreSelect只需要query中个别字段,我们可以更进一步实现一下我们获取state的方法,从state中只获取我们需要的。可以加入一个selector function选取需要的数据。 const useAppStore = ( selector?: (state: QueryState) => any ): [Partial<QueryState>, (p: Partial<QueryState>) => void] => { const storeCtx = useContext(AppContext)!; const defaultSelectState = selector ? selector?.(storeCtx.getState()) : storeCtx.getState(); const [state, setState] = useState(defaultSelectState); // add subscribe useEffect(() => { const unsubscribe = storeCtx.subscribe((s: QueryState) => { const selectState = selector ? selector(s) : s; setState(selectState); }); return () => unsubscribe(); }, []); return [state, storeCtx.setState]; }; const ScoreSelect = () => { const [score] = useAppStore((state) => state.score); ...省略 }; 这样子做的好处是,如果添加一个只关注score的变化的组件,只有当score变化时,我们[只关注score]的组件才会重新渲染,其他的变化不会引起渲染,又做到了进一步优化。 最后重新组织代码 // createStoreContext import { createContext, PropsWithChildren, useContext, useEffect, useRef, useState, } from "react"; import { createStore, Store } from "./createStore"; function createStoreContext<T>(initState: T) { const StoreContext = createContext<Store<T> | null>(null); const StoreProvider = ({ children }: PropsWithChildren) => { const storeRef = useRef<Store<T>>(); if (!storeRef.current) { storeRef.current = createStore(initState); } return ( <StoreContext.Provider value={storeRef.current}> {children} </StoreContext.Provider> ); }; const useStore = ( selector?: (state: T) => any ): [Partial<T>, (p: Partial<T>) => void] => { const storeCtx = useContext(StoreContext)!; const defaultSelectState = selector ? selector?.(storeCtx.getState()) : storeCtx.getState(); const [state, setState] = useState(defaultSelectState); // add subscribe useEffect(() => { const unsubscribe = storeCtx.subscribe((s: T) => { const selectState = selector ? selector(s) : s; setState(selectState); }); return () => unsubscribe(); }, []); return [state, storeCtx.setState]; }; return { StoreProvider, useStore, }; } // AppContext export type QueryState = { name?: string; team?: string; age?: string; score?: string; }; const initState: QueryState = { name: "", team: "", age: undefined, score: undefined, }; const { StoreProvider: AppContextProvider, useStore: useAppStore } = createStoreContext(initState); export { AppContextProvider, useAppStore }; // App import { AppContextProvider, useAppStore } from "./AppContext"; function App() { return ( <AppContextProvider> <AppContainer /> </AppContextProvider> ); } // other const ScoreSelect = () => { const [score, setQuery] = useAppStore((state) => state.score); ...省略 };

12
life.caoabout 3 years

装饰器模式的实践——Decorator

介绍 定义 装饰器(Decorator)模式的定义:指在不改变现有对象结构的情况下,动态地给该对象增加一些职责(即增加其额外功能)的模式。 装饰模式3个角色: Client客户 维护对修饰组件的引用 Component组件 向其添加附加功能的对象 Decorator室内设计师 通过维护对该组件的引用来“包装”该组件 定义一个符合 Component 接口的接口 实现附加功能(图表中的 addedMember) 装饰器模式的主要特点: 在不改变原有对象的情况下,动态的给一个对象扩展功能,符合开闭原则。(扩展开放,修改封闭) 通过使用不同装饰类及这些装饰类的排列组合,可以实现不同效果。 装饰器模式会增加许多子类,过度使用会增加程序得复杂性。 什么时候用 在以下两种场景中我们可以考虑装饰器模式: 希望在不影响现有对象功能的情况下更改对象 减少处理一些API的重复代码,通过包装让模块更好的复用、更好的维护 假如我们有一只猫,它有睡觉、吃饭等方法,我们也有电器,有充电功能。这两个类互不干扰。 class Animal{ constructor(){ // } sleep(){} cry(){} eat(){}}class Mechine(){ constructor(){ // } charge() {} work() {}}class Cat extends Animal{ constructor(){ // } sleep(){} }class Computer extends Mechine {}const cat = new Cat();const computer = new Computer(); 但是有一天我们有一只机器猫了,这只猫不仅可以叫还可以工作 class RobotCat extends Cat, Mechine { constructor(){}} JS继承通过原型链实现,原型链只有一个,只能继承一个类,以上代码不生效。即使是生效的,也会带来一些问题,机器猫不能睡觉,假如继承这两个类,会继承所有的方法,使得该类变得臃肿。设计原则中强调组合优于继承。如果可以通过包装组合的方式实现,将会更加灵活。 关于@Decorator ES7中decorator借鉴Python中@decorator的语法糖,基于Object.defineProperty()方法实现。 Object.defineProperty():它会直接在一个对象上定一个新属性,或者修改一个对象的现有属性,并返回此对象。具体的语法是: Object.defineProperty(obj, prop, descriptor)// obj:要定义属性的对象// prop:要定义或修改的属性的名称或Symol// descriptor:要定义或修改的属性描述符。// 可通过修改descriptor.value改变对象行为 ClassDecorator 类装饰器 declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;// 装饰器使用单个参数调用,该参数是要装饰的构造函数。 通过类装饰器实现行为“继承”,只需要通过@moveDecorator 任意的类都有getPosition方法。 const moveDecorator: ClassDecorator = (target: Function) => { target.prototype.getPosition = (): { x: number; y: number } => { return { x: 100, y: 200 }; };};@moveDecoratorclass User {}const user = new Tank();console.log(user.getPosition());// { x: 100, y: 200 }// 这里@moveDecorator语法糖的作用与调用moveDecorator(User) 方法相当 MethodDecorator函数装饰器 declare type MethodDecorator = <T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;// target:如果装饰器装饰的是静态成员,则target是类本身;如果装饰器修饰的是实例成员,则target是该类的protot// propertyKey:当前修饰的键值// descriptor:该key的属性描述符,可读可写,成员装饰器不能有返回值。 实现某个方法的延时调用,通过@SleepDecorator(2000),show方法在调用的时候会被延迟2秒执行。 const SleepDecorator = (times: number): MethodDecorator => { return ( target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>, ) => { // 将该方法原来的值通过method存储 const method = descriptor.value; // 重新赋值该方法 descriptor.value = () => { setTimeout(() => { method(); }, times); }; };};class User { @SleepDecorator(2000) public show() { console.log("delay 20000 method decorator"); }}const u = new User();u.show();// 20000ms 后输出 delay 20000 method decorator PropertyDecorator属性装饰器 declare type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void; 我们通过属性装饰器来生成随机数,通过@RandomNumber,直接对类的实例生成number随机数。 const RandomNumber: PropertyDecorator = ( target: Object, propertyKey: string | symbol,) => { Object.defineProperty(target, propertyKey, { get() { return Math.random() * 10; }, });};class User { @RandomNumber public number: string | undefined;}const u = new User()console.log(u.number);// 控制台显示0-10的随机数 @Decorator语法糖,让“装饰器”应用代码实现更加的简洁和优雅,目前还属于未进入稳定阶段,我们可以用babel来转换为浏览器能识别的语言,未来ES7的这个语法可以让我们方便对类、属性的装饰。 但是目前@Decorator还不能作用于函数,只能用于类和类的方法,也就是React开发中我们常用到的函数式组件无法使用该特性。 应用 常见防抖 业务中常用到的防抖、节流的设计也是装饰器应用。 Debounce function debounce(timeout) { const instanceMap = new Map(); // Create a Map data structure with the instantiated object as the key return function (target, key, descriptor) { return Object.assign({}, descriptor, { value: function value() { clearTimeout(instanceMap.get(this)); instanceMap.set( this, setTimeout(() => { descriptor.value.apply(this, arguments); instanceMap.set(this, null); }, timeout), ); }, }); };}class Editor { content: any; constructor() {} @debounce(500) updateContent(content) { console.log(content); this.content = content; }}const e = new Editor();e.updateContent("11111");e.updateContent("22222");e.updateContent("33333");// 控制台最终输出 33333 高阶组件 React中的高阶组件也类似一个装饰器的作用。 React中HOC接收一个组件作为参数,并返回一个新的组件。我们基于装饰器原理,对传入函数进行功能的装饰,将一个组件转换成另一个组件。 下面的例子我们通过类似插槽的方式将组件作为children传递给父级组件,父组件返回带有子组件的渲染。这种组合的形式也更加的灵活。 <Swiper defaultIndex={defaultIndex} direction="vertical" style={{ '--height': '100vh', }} onIndexChange={(index) => { setCurPage(index); }} > <Swiper.Item> <LottieContainer path={lottiePathMap.P2}> <Page1 /> </LottieContainer> </Swiper.Item> <Swiper.Item> <LottieContainer path={lottiePathMap.P3}> <Page2 /> </LottieContainer> </Swiper.Item> </Swiper> 业务开发中,企业PC端基于ant组件库进行二次封装,使其组件库风格与业务设计风格贴合。 二次组件封装 import { Pagination } from 'antd';import IArrow from '@imgs/icons/arrow-right.svg';export default function ResetPagination(props: any) { const itemRender = (current: number, type: any, originalElement: any) => { if (type === 'prev') { return ( <span className='page-arrow-left'> <IArrow /> </span> ); } if (type === 'next') { return ( <span className='page-arrow-right'> <IArrow /> </span> ); } return originalElement; }; return ( <Pagination {...props} itemRender={itemRender}> {props?.children} </Pagination> );}// 我们将Pagination封装成ResetPagination,修改了上一页下一页的按钮。 HOC更趋向于复用可视化的逻辑,如果需要复用非可视化的逻辑,需要使用到React Hooks。 其他“易混淆”的模式 代理模式 代理模式的特点在于隔离,隔离调用类和被调用类,通过一个代理去调用。为其他对象提供一种代理以控制对这个对象的访问。 如在项目中用到的接口请求处理。 最终我们通过暴露POST方法给外部,对axios进行了一层隔离。这在项目中是很常见的处理,可以更好的处理请求公共逻辑。以下是代码实现。 export const request = axios.create({ baseURL: apiHost, withCredentials: true,});...request的处理,此处省略...export const POST = (...args: PostParams) => { return request.post(...args).then((r) => r.data);}; ES6语法中,我们还可以用Proxy来创建对象的代理,实现对对象的拦截。 适配器模式 适配器是两个不兼容的接口之间的桥梁。适配器不是在详细设计时添加的,而是解决正在服役的项目的问题。 如在项目中看到坐标转换的例子,后端返回坐标系A的数据,我们通过调用百度api将其转成百度坐标系。但是接口返回的坐标系并不是api中约定的格式,而百度坐标系转换后的坐标也不是我们需要的最终格式。我们在中间增加一个适配层进行入参和出参的格式处理。 暂时无法在飞书文档外展示此内容 入参需要将接口返回的对象数据类型进行转换成百度坐标转换器需要的字符串形式;出参将百度坐标转换器返回的Point数组类型转换成对象数据类型。以下是代码 // 调用百度api的方法function getGeoconvMultiterm(coords, from = 1, to = 5) { return new Promise((resolve, reject) => { const convertor = new BMap.Convertor(); convertor.translate(coords, from, to, data => { if (data && data.status === 0) { resolve(data.points || []); } else { reject(data); } }); });}// const batchTransform = async function ({ coords, revealFn, from, to, returnLatField, returnLonField,}) { const coordsArr = coords.split(';'); // 单次请求可批量解析10个坐标 const SINGLE_TRANSFORM_MAX = 10; // 请求次数 const requestFrequency = Math.ceil(coordsArr.length / SINGLE_TRANSFORM_MAX); // promise集合 const promises = []; // promise分批请求 for (let currentRF = 0; currentRF < requestFrequency; currentRF++) { const sliceArr = coordsArr.slice( currentRF * SINGLE_TRANSFORM_MAX, (currentRF + 1) * SINGLE_TRANSFORM_MAX, ); const pointsArr = sliceArr.map(e => { const point = e.split(','); return new BMap.Point(point[0], point[1]); }); promises.push(getGeoconvMultiterm(pointsArr, from, to)); } const promiseRes = await Promise.allSettled(promises); // promise结果处理 const result = promiseRes.map((p, index) => { if (p.status === 'fulfilled') { return p.value.map(e => ({ [returnLatField]: e.lat, [returnLonField]: e.lng, })); } else { // 兜底转换 const sliceArr = coordsArr.slice( index * SINGLE_TRANSFORM_MAX, (index + 1) * SINGLE_TRANSFORM_MAX, ); sliceArr.map(e => { const latlonArr = e.split(','); const bds = revealFn(latlonArr[0], latlonArr[1]); return { [returnLatField]: bds.lat, [returnLonField]: bds.lon, }; }); } }); // 二维数组解构 const arr2 = result.reduce((a, b) => a.concat(b)); return arr2;};export const batchWgsToBd = async function ( coords, returnLatField = 'lat', returnLonField = 'lon',) { return batchTransform({ coords, revealFn: wgs2bd, from: 1, to: 5, returnLatField, returnLonField, });};// 页面使用:由于接口定义可能不一致,为避免多次转换,我们在实际页面中调用转换器之前进行入参数据结构转换; const coords = this.pointUpList.map(e => e.pointLatlon).join(';'); const pointPath = await batchWgsToBd(coords, 'lat', 'lng'); 总结 这三种模式都属于结构性设计模式,个人觉得他们主要区别是设计目的和意图的不同,实际在开发过程中并无明显的分界线。 1、装饰器模式从设计开始预测未来使用场景,识别出“装饰性”的功能,再进行代码设计,代理模式中要设计中间层逻辑控制访问对象的安全性。两者有异曲同工之处。 2、装饰器模式在不影响原有的功能基础上增加的功能,这一点跟适配器的情况也有些殊途同归。 在开发过程中,不需要指定一定要使用某种设计模式来实现功能,他们都有些相似。不管怎样,他们都是实现的工具,让我们更好的保持开发者思路去设计和编程。

6
life.caoabout 3 years

WebP方案的探索与实践

背景 当我们在做网站性能优化的时候,减少图片大小,拆分大体积的包都是可采用的方法, 那么对于减小图片的大小, 我们可以通过进行压缩图片处理,可是压缩比例一直是前端开发和设计师争执的焦点。压缩比例大的话,可以有效减少图片体积,对页面加载有利,但是却损失了像素,是设计师无法容忍的。 在这种矛盾的场景下,我们既要最大程度的压缩图片又要保持足够的清晰,WebP 便应运而生 Webp是什么? 引自Google 解释 WebP is a modern image format that provides superior lossless and lossy compression for images on the web. Using WebP, webmasters and web developers can create smaller, richer images that make the web faster. 我们可以看到几个比较关键的字样lossless and lossy, smaller 、richer、faster,原文翻译为它是一种提供有损压缩和无损压缩的图片格式,可以创建更小、更丰富的图片,使网页加载更快。 相比较Jpg\png\jpeg有什么优势? 优势体现在同质量的前提下,WebP体积大约只有JPEG的1/3,对于采用大量图片的网页,WebP格式可以节省大量带宽,大幅提升网页加载速度;同时具备了无损和有损的压缩模式、Alpha 透明以及动画的特性, 下面是对比 PNG 原图、PNG 无损压缩、PNG 转 WebP(无损)、PNG 转 WebP(有损)的压缩效果: 从上图我们可以得出结论 webp的体积相较于原图有较大的减小 png转为webp之后的压缩率高于 PNG 原图压缩率 那么对于webp各大浏览器的兼容性是怎样的呢? 我们从caniuse.com查看webp在浏览器的支持率,可以看出chrome/android浏览器已经全量支持了,在高版本的safari中也已经支持了,但是在一些低版本的Ie 和safari中不支持 即使是在全球范围内已经有了96.8% 的兼容率,在国内大概也有80.88% 的兼容率,但是我们在使用时也是要考虑在低版本浏览器的兼容性,下文中也有对兼容性的处理。 使用webp的收益 可以降低获取图片的时间 我们来看一个图片对比图 我们可以看到一张同样的一张图片格式不同,webp 格式的只有png的1%, 图片体积锐减, 那么随之我们页面加载的速度也会更快。大家可以自己体验下。 带宽成本 我们以市场上各大互联网公司为例, YouTube 的视频略缩图采用 WebP 格式后,网页加载速度提升了 10%; 谷歌的 Chrome 网上应用商店采用 WebP 格式图片后,每天可以节省几 TB 的带宽,页面平均加载时间大约减少 1/3; Google+ 移动应用采用 WebP 图片格式后,每天节省了 50TB 数据存储空间。 在上文中,我们已经看到了使用webp带来的收益,那么在我们的项目中应该怎么去使用webp 以及在引用时都有哪些要求呢?大家可以接着往下看,大致可以罗列为以下三点: 引入webp要求 既可以支持远端资源的引入,又可以支持本地资源的引入 在项目中可以支持设置webp的覆盖率,根据项目使用webp时的反馈情况,逐渐放开webp的使用量 只加载使用的类型的资源 方案要点 在本地资源中,如何根据jpg|jpeg|png 生成 webp 在生成webp之后,如何根据浏览器的支持以及放量的覆盖,在业务Diy组件中引入对应的图片资源 如何引入公用的css module ,不需要在每个组件的css module中引入 解决引入要求及阐述要点 如何根据jpg|jpeg|png 生成 webp 我们可以看webp的官文介绍 WebP includes the lightweight encoding and decoding library [libwebp](https://developers.google.com/speed/webp/docs/api) and the command line tools [cwebp](https://developers.google.com/speed/webp/docs/cwebp) and [dwebp](https://developers.google.com/speed/webp/docs/dwebp) for converting images to and from the WebP format, as well as tools for viewing, muxing and animating WebP images. The full source code is available on the download page. 有三种方案进行生成webp a.使用[libwebp](https://github.com/webmproject/libwebp/blob/main/webp_js/index_wasm.html)将其他格式的图片转为webp格式 优点:能在浏览器享受WebP格式的优势。 缺点:实时转换格式,消耗浏览器性能;有一定的延迟;在css中的引用无法处理 b.使用命令行进行生成 # 可以使用 brew install cwep进行安装 # 运行 cwebp -h 命令检查 cwebp 是否安装ok # cwebp [options] input_file -o output_file.webp # options 我们只需关心 -q即可 其他参数大家可以查阅cwep的具体参数 # -q 质量指数(压缩率),有损压缩有效,无损压缩忽略 # -o 输出图片名称格式 cwebp -q 75 input_file -o output_file.webp 优点:可以写脚本进行动态生成webp,不依赖于任何三方 c.借助一些网站进行生成webp 例如 anywebp 优点: 快速生成,只需进行拖拽即可 缺点: 需要预处理,耗费人力 H5端 分为两部分,一是在css样式中支持webp,另外一种是image src 的引入 image src 的引入 重写Image组件 在baseImage组件中根据 import React, { useMemo } from 'react' import webpInstance from 'app/webp' interface IBaseImage { src: string isLocal?: boolean // 是否是引入本地图片 className?: string [key: string]: any } const BaseImage = ({ src, isLocal = false, className, ...props }: IBaseImage) => { const isLunaSrc = useMemo(() => { return src.indexOf('luna') !== -1 }, [src]) const imgSrc = useMemo(() => { if (webpInstance.support) { if (isLocal) { return src } else { return isLunaSrc ? `${src}?x-oss-process=image/formate,webp` : src } } else { return src } }, [isLocal, isLunaSrc, src]) as unknown as string return <img className={className} {...props} src={imgSrc} alt="" /> } export default React.memo(BaseImage) WebP方案的ROI 在我们调研一种技术方案的时候, 都要评估该技术引入的收益,也是我们所说的**ROI** 如果你决定使用WebP的话,以下数据可能会对你有所帮助 图片大小减小到原来的1/6 DCL由原来的3.79s降低到2.76s,下降了27%,FCP由原来的4.53s降低到3.32s,下降了26.7% 左右 使用正常png 使用Webp之后 总结 我们从Webp的使用背景以及收益出发,介绍了什么是webp、兼容性、生成方式等,以及在业务项目中是如何灰度的使用,导出全量图片资源的引用 路径,还有后面在业务项目中是如何实践,所产生的收益。

688
life.caoabout 3 years

web 端动画的实现和探索

开发移动端项目时,特别是活动页面,免不了会涉及动画。而且好的动画会给产品加分。本文总结了 web 端常见动画实现的方式,希望读者在阅读完本文后,掌握动画开发技巧,更加自信地解决交互设计师提出的需求。 Toolbox 实现 web 端动画有多种方式,常见的有 GIF/APNG,CSS3,JavaScript,lottie,SVG,canvas 等。具体在业务中使用何种方式,需要综合考虑实现成本与运行效率。需要对动画进行较多控制的,使用 JavaScript 或 lottie,其他使用 CSS3 与 GIF/APNG。 APNG 在上述的动画方式中,GIF/APNG 无疑最省力。相较于 GIF,APNG 更有优势(参考这篇文章)。简单的场景下,使用 APNG 与 setTimeout 也可以实现流程的控制。 不过于在实际使用的时候,注意以下问题: APNG 预加载 通常 APNG 图片尺寸较大,最好提前加载。 APNG 不重复播放 已缓存的 APNG 图片再次显示时,动画不播放。你可以在链接中通过 query 参数来重新加载图片。 CSS3 大家应该在项目中广泛使用 CSS 动画,transition/animation 是动画利器。常见的过渡效果通过 CSS 都可以实现。如果没有思路的话,不妨去 Animate.css 或者 Animista.net 上看看,也许答案就在上面。 几点提示 animation-fill-mode 该属性设置在动画结束后,保持终止状态还是恢复初始状态。经常使用 animation-fill-mode: forwards; 来保持终止状态。 animation-function 中的 steps 函数 一般下,动画的过渡的是连续的,通过 steps 函数可以让动画「断断续续」(每次切换一帧),实现帧动画的效果。借助该特性,实现一个简单的倒计时。 好用的 clip-path clip-path 属性用于控制元素的显示区域。类似效果,使用 overflow: hidden; 也可以实现,但是代码量会增多,且对于多边形的裁剪就无能为力。 动画性能 尽可能使用 transform,避免改变布局,给动画区域添加 will-change 属性。如果同时要对大量的 DOM 元素做动效,或许你应该尝试使用 canvas 而非 CSS。 Vue 的封装 Vue 对于动画的进行了封装,提供 transition 与 transition-list 组件,过渡/动画用一套 API。Vue 中的过渡两个特性值得关注: 过渡模式 mode 同时对离开与出现的元素添加过渡效果。红包点击效果:点击红包后,红包隐藏,同时展示t + 1 的提示。 记住这个例子,下文中还会提及。 无法复制加载中的内容 列表过渡 transition-list 给列表新增的元素添加过渡效果。下方的跑马灯效果就用到了 transition-list 。 无法复制加载中的内容 Lottie、SVG 对于复杂的动画,推荐使用 Lottie,并且动画制作软件 AE 支持导出 Lottie 文件。Lottie 使用方式极其简单,导入 JSON 文件完成动画的创建。 import lottie from 'lottie-web'; import animData from './animData.json'; const anim = lottie.loadAnimation(animData); 配合 lottie-api,你甚至可以编辑原有的动画。 无法复制加载中的内容 Lottie 相当于使用 JS 来播放动画,你可以对动画的播放速度,帧数,次数,顺序进行精准的控制。一些事件或者方法参考官方文档,这里不再赘述。 注意事项 如果 Lottie JSON 文件引入图片资源,要去调整图片的路径,避免出现 404 问题。当然更好的方法是使用其他的自动化工具比如 lottie-loader 来处理 JSON 文件,调整图片路径。在使用了 lottie-loader 的前提下,你可以直接把 JSON 文件当做组件来用。 import MyAnimate from './data.json' export default { components: { MyAnimate } } Lottie JSON 字段含义 遗憾的是, Lottie 官方文档并没有介绍 JSON 中各字段的含义,只能在代码仓库中找到一些 JSON schema 描述文件。下面对部分字段做简要描述,当你有定制化需求时,或许需要: { "fr": 20, // 每秒播放的帧数 "ip": 0, // 动画开始的开始帧数 "op": 40, // 动画开始的结束帧数 "w": 700, // 动画内容区域宽度 "h": 500, // 动画内容区域高度 "assets": [{ // 图片资源,为避免 404 问题,你需要编辑这里 "w": 120, // 图片宽度 "h": 120, // 图片高度 "u": "images/", // 图片路径 "p": "img_0.png"// 图片名称 }] } 由 fr , ip 与 op 可知动画播放一个周期需要 2s。通过调用 setSeed(2) 可以将播放时间降为 1s;通过 play(frame) 或者 goToAndPlay(frame) 类似的方法设置动画从某一帧开始播放。 Lottie-loader 的原理就是处理了 assets 中的图片路径 动画的实践指南 借助上文中提到了多种工具,处理简单的动画不在话下。对于稍微复杂的动画,要用 JS 去编写动画逻辑。以下面的动画效果为例,来讲解实现思路。 通过分析发现,主要的逻辑集中在红包上。红包有停止,暂停两种状态;按照某个频率去顶部还会落下新的红包;按照某个频率移除可视区范围外的红包;移动的过程中旋转红包。 编写代码时,为了提高灵活性,最常用的策略是数据抽象与过程抽象。经过上面的分析,先进行数据抽象,把红包雨整个区域用数据来描述。 // 动画区域 class Stage { // ... children: Packet[] duration: number destroyed: boolean } class Packet { x: number y: number degree: number rotation: 'clockwise' | 'anticlockwise' status: 'idle' | 'moving' | 'removed' } 接下来,进行过程抽象,添加方法,让数据动起来。由于存在多种频率不同的动画,把一种动画想象成一个 task,用 async 函数描述过程。 class Stage { init() { } // 按照一定的频率添加新的红包 async add() { while (true) { if (this.destroyed) return await wait(200) // 执行动画逻辑 ... } } // 与 add 类似,让红包移动,同时做清理工作 async animateAndClear() { } } class Packet { // 设置停止 stop() { } } 通过前面两步,完成了对红包雨整个动效的抽象。此时,动画已经是“运动”的了。接下来都是 View 层的工作。借助我们已经掌握的技能,View 层不难实现。 在项目开发过程中,使用的框架是 Vue。如果你想用 canvas 去实现,数据层可以复用,只需要针对 View 层做适配工作即可。 状态的复用与组件抽象 React 与 Vue3 都提供了对 Hook 的支持,数据或者处理数据的逻辑(Hook)是可以复用的。在基础组件库日益丰富的当下,View 层的工作以组合基础组件为主,相当多的业务逻辑是处理数据。回到刚才红包雨的例子,我们将先考虑状态的设计,再剥离状态,类似于对 Lottie/Hook 的简单模仿。毕竟调整数据的配置比编写业务代码成本低得多。 Hook 与动画 上文提到的「红包点击」例子,如果用 Hook(ReactSpring) 来实现的话,核心代码如下: import { useTransition, animated } from "react-spring"; export default function App() { // ... const transitions = useTransition(show, null, { from: { opacity: 0 }, enter: { opacity: 1 }, leave: { opacity: 0 } }); return ( <div class="animation-container"> {transitions.map(({ item, key, props }) => { return item ? ( <animated.div style={props} key="plus"> <img src="plus.png" /> </animated.div> ) : ( <animated.div style={props} key="packet"> <img src="packet.png" /> </animated.div> ); })} </div> ); } 完整的代码点击:https://codesandbox.io/s/red-packet-transition-smzm1 从上面的例子可以看出,我们只需要设置好初始与终止状态,然后再与 View 层做绑定,整个动画就完成了,便捷程度堪比 CSS3。在先前 Vue 的实现中,依赖 transition 组件的功能,而 ReactSpring 则是整个过渡的状态提供给你,你决定如何去渲染。 如果后续需要对该场景进行封装,那么大概会这样拆分: 过渡状态抽象成 Hook,对外导出 用 Hook 实现一个组件,默认导出该组件 用户如果对组件不满意,是完全可以复用 Hook 实现新的组件,代码量并不大。随着 Hook 概念的推广,我们也要逐渐学习掌握它。开发项目过程中,有意识地去思考如何对数据进行抽象,如何更好的管理数据。 本文未涉及的内容 本文主要讨论的是 web 端简单的动画实现方式。对于更为复杂的场景,如 HTML 5 游戏,为了性能与开发效率的考量,建议使用 pixi.js 或者 phaser 之类的游戏框架。 总结 丰富你的工具箱,掌握更多工具 对需求进行拆分,对数据抽象/过程进行抽象,提高可维护性 学习 Hook,思考状态逻辑如何复用

71
life.caoabout 3 years

库开发者需要了解的的 babel

库开发者需要了解的的 babel babel 仓库下含众多的包,如果从零开始学习,恐怕会陷入过多细节而无法脱身。好在借助成熟的脚手架工具,无需关注细节也能确保项目平稳上线。但是作为开发者,仍有必要掌握一定的 babel 知识,当遇到编译出错或者线上白屏问题时,至少不会手足无措,知道该从哪里去解决问题。 考虑到 babel 体系纷繁复杂,我们不介绍 babel 下众多的包的用法,而从脚手架中广泛应用的 babel-preset 谈起。大家在项目中经常使用的应该是 @vue/cli-plugin-babel/preset 或者 babel-preset-react-app 这两个 preset 了。由于这两个包的实现类似,不妨从 @vue/cli-plugin-babel/preset 谈起。 由 @vue/cli-plugin-babel/preset 谈起 @vue/cli-plugin-babel/preset 包含了编译 Vue 项目中 JavaScript 所需的配置。完整的代码点击**这里**,代码比较长,简化一下,大概做了以下几件事情: 配置 @babel/preset-env 等插件,根据源代码的自动注入需要的 polyfill presets.unshift([require('@babel/preset-env'), envOptions]) 配置 @vue/babel-preset-jsx 插件,从而编译 jsx presets.push([require('@vue/babel-preset-jsx'), jsxOptions]) 注册 vue-cli-inject-polyfills 插件,向代码头部导入 es.array.iterator,es.promise.finally 之类的 polyfill 配置 @babel/plugin-transform-runtime,提取通用 helper 函数,编译过程中共用同一个 @babel/runtime plugins.push([require('@babel/plugin-transform-runtime'), {...}) 由此可以看出,在 Vue 项目中我们可以大胆地使用 JavaScript 新特性而无需担心兼容性问题。 webpack 中的配置 babel 只能编译单文件,如果要是编译一整个项目,需要 webpack 之类的打包工具。webpack 通过使用 babel-loader 来实现对 JavaScript 的编译与打包。vue-cli 项目中的 webpack 配置片段如下: const jsRule = webpackConfig.module .rule('js') .test(/\.m?jsx?$/) .exclude .add(filepath => { // always transpile js in vue files if (/\.vue\.jsx?$/.test(filepath)) { return false } // ... // check if this is something the user explicitly wants to transpile if (transpileDepRegex && transpileDepRegex.test(filepath)) { return false } // Don't transpile node_modules return /node_modules/.test(filepath) }) .use('babel-loader') .end() 观察上面的配置,发现 babel-loader 并不会编译 node_modules 下的文件。 这样做的好处是提升了**构建速度**。 不过 vue-cli 仍然提供一个名为 transpileDependencies 的配置,来编译 node_modules 下指定模块。但是作为库的开发者,提供标准的产物是应尽的责任,不应该给用户带来额外的负担。 非法导出 避免直接导出源代码(jsx/vue/ts/tsx),原因如下: 这可能会导致兼容性问题 结合上面的讨论,babel-loader 不会编译 node_modules 下的代码。为了避免运行出错,这就要求第三方库代码经过编译,做好 polyfill。 使用者需要引入额外的 loader .jsx/.vue/.ts/.tsx 需要经过专门的 loader 处理后才转换成 JavaScript,用户如果不引入额外的 loader 是无法编译源到的。 为什么我在 npm 包里导出源代码没有报错? 项目中对文件特定类型的文件做特殊的配置,比如 vue-cli 项目会使用 babel 编译所有的 .vue/.jsx 文件。除非你了解项目中的 webpack 细节,否则还是老老实实编译代码。 // always transpile js in vue files if (/\.vue\.jsx?$/.test(filepath)) { return false } 导出正确的模块 区分 commonjs 与 esm,package.json 中的 main 导出 commonjs 文件,module 导出 esm 文件。webpack 在打包的时候,会优先使用 esm,且能做 Tree Shaking 优化。2021 年的库开发者应该面向未来,都在包内提供 esm。 为什么我在 main 中导出了 esm 没报错? Webpack 的模块解析机制支持 require 与 import 语句,并不区分 commonjs 与 esm。但是如果尝试在 Node.js(LTS) 中执行 esm 代码,则会直接报错。 import fs from 'fs'; console.log(fs.statSync(process.cwd())); // 报错信息 // node:30467) Warning: To load an ES module // set "type": "module" in the package.json or use the .mjs extension. 另外,esm 是未来的规范,已经被高版本的浏览器支持,但是 commonjs 文件是无法直接运行在浏览器中的。 Typescript 与 babel 现在越来越多的项目在使用 typescript 了,推荐大家去学习并应用。使用 tsc 当然可以编译 .ts 文件,但是在实践上,我们通常使用 tsc 去检查语法,校验类型,而使用 babel 去完成编译工作。所以常见的 typescript 项目 babel 配置大致如下: { "presets": [ "@babel/env", "@babel/preset-typescript" ], "plugins": [ "@babel/plugin-transform-runtime" ] } 总结 本文简要分析 babel 在 Vue 项目中的应用,协助大家理解常见的 babel 概念。一篇文章显然无法涵盖所有细节,推荐结合真实的项目去应用 babel。

56
life.caoover 3 years

异步调用的一致性问题—— useRequest

需求:写一个组件,根据传入的 ID,请求某个后端接口,把数据展示出来。 Level 1. 功能开发完成 import { useEffect, useState } from 'react'; export default ({ id }) => { const [data, updateData] = useState(null); useEffect(() => { fetch(`/api/xxx?id=${id}`) .then((res) => { return res.json(); }) .then((data) => { updateData(data); }); }, [ id ]); return <div>{data}</div>; }; 问题:缺少 Loading 状态 组件首次加载,缺少 Loading 确实只是一闪而过的空白状态,影响的只是用户体验。但如果是 组件更新 的加载,那影响就大了。 事情是这样的。在一个运营后台,运营在编辑营销策略的时候会切换多个活动。编辑的表单就是一个组件,这个组件就是接收一个活动 ID 作为参数。由于接口响应比较慢,下一个活动配置加载完成前页面依然显示上一个活动的信息,但是 ID 已经更新了。运营看到自己在编辑 A 活动,结果保存的时候前端代码把这个营销策略保存到了 B 活动上,导致优惠大量超发,造成上百万的资损。 问题:缺少异常状态处理 异常状态 不处理也一样会出现上面的问题。运营可能从 A 活动切换到 B 活动,但是 B 活动加载失败了(比如后端接口网关 502 了),运营依然看到 A 活动的内容,但 ID 已经切到了 B 活动,这时做了保存操作你猜会怎么着? Level 2. 全状态处理 import { useEffect, useState } from 'react'; export default ({ id }) => { const [data, updateData] = useState(null); const [loading, updateLoading] = useState(false); const [error, updateError] = useState(null); useEffect(() => { updateLoading(true); fetch(`/api/xxx?id=${id}`) .then((res) => { return res.json(); }) .then((data) => { updateData(data); updateError(null); }, (error) => { updateData(null); updateError(error); }) .finally(() => { updateLoading(false); }); }, [ id ]); if (loading) return <div>Loading···</div>; if (error) return <div style={{ color: 'red' }}>{error.message}</div>; return <div>{data}</div>; }; 问题:缺少一致性保障 当 ID 频繁变化时会发起多次请求,由于接口响应的时序不可确定,Promise 走到 then 的时候不能确定是哪一次请求的结果,有可能结果和请求不匹配。 Level 3. 一致性保障 import { useEffect, useState, useRef } from 'react'; export default ({ id }) => { const [data, updateData] = useState(null); const [loading, updateLoading] = useState(false); const [error, updateError] = useState(null); const lock = useRef(0); useEffect(() => { updateLoading(true); const inc = ++lock.current; fetch(`/api/xxx?id=${id}`) .then((res) => { return res.json(); }) .then((data) => { if (lock.current !== inc) return; updateData(data); updateError(null); }, (error) => { if (lock.current !== inc) return; updateData(null); updateError(error); }) .finally(() => { if (lock.current !== inc) return; updateLoading(false); }); }, [ id ]); if (loading) return <div>Loading···</div>; if (error) return <div style={{ color: 'red' }}>{error.message}</div>; return <div>{data}</div>; }; 扩展内容 使用 ahooks 的 useRequest 自己手动处理乐观锁的代码写起来太恶心了,如果用 ahooks 的 useRequest 的话可以省事很多。它内部也有类似的乐观锁逻辑,见 useAsync.ts#L133-L136。 import { useRequest } from 'ahooks'; export default ({ id }) => { const { data, loading, error } = useRequest(() => { return fetch(`/api/xxx?id=${id}`).then((res) => { return res.json(); }); }, { refreshDeps: [ id ] }); if (loading) return <div>Loading···</div>; if (error) return <div style={{ color: 'red' }}>{error.message}</div>; return <div>{data}</div>; }; useRequest 的错误使用姿势 但即便用了 useRequest,如果你的使用姿势不对,依然会出问题,比如这样: import { useRequest } from 'ahooks'; import { useState } from 'react'; export default ({ id }) => { const [data, updateData] = useState(null); const { loading, error } = useRequest( () => { return fetch(`/api/xxx?id=${id}`).then((res) => { return res.json(); }); }, { refreshDeps: [ id ], onSuccess: (data) => { updateData(data); }, }, ); if (loading) return <div>Loading···</div>; if (error) return <div style={{ color: 'red' }}>{error.message}</div>; return <div>{data}</div>; }; 总结 写前端组件都要考虑全状态处理,任何未处理的状态都是潜在的 Bug; 任何异步调用都要考虑一致性问题,这类问题通常加锁来解决;

4
life.caoover 2 years

主流css置灰写法

主流是如何实现置灰的 大致代码如下: html.o2_gray { -webkit-filter: grayscale(100%); -moz-filter: grayscale(100%); -ms-filter: grayscale(100%); -o-filter: grayscale(100%); filter: grayscale(100%); -webkit-filter: gray; filter: gray; -webkit-filter: progid:dximagetransform.microsoft.basicimage(grayscale=1); filter: progid:dximagetransform.microsoft.basicimage(grayscale=1); } 可以看到,这些网站实现置灰的方式都差不多。 核心就是filter: grayscale(100%); 其他的几条filter 相关的CSS规则都是为了对属性进行兼容处理。 can i use 的情况 MDN 对 filter 属性的定义: CSS属性 filter 将模糊或颜色偏移等图形效果应用于元素。滤镜通常用于调整图像、背景和边框的渲染。 // 使用方式 filter: <filter-function> [<filter-function>]* | none filter 属性就是用来给元素添加不同的滤镜。该属性中支持传入多种 Filter 函数,其中 grayscale() 函数就是用于置灰的关键。grayscale() 函数将改变输入图像灰度,该函数有一个参数,表示转换为灰度的比例。当值为 100% 时,完全转为灰度图像;当值为 0% 时图像无变化。 这里使用有个要注意的地方! 文档来源 “filter属性的值不是 none 会导致为绝对和固定定位的后代创建一个“块”,除非是应用在上下文根元素上” 通俗讲:对于指定了 filter 样式且值不为 none 时,被应用该样式的元素其子元素中如果有 position 为 absolute 或 fixed 的元素,会为这些元素创建一个新的容器,使得这些绝对或固定定位的元素其定位的基准相对于这个新创建的容器。 相关参考链接 比如网站的头部是吸顶的,如果是在body 加会导致吸顶失效。 Fixed: 正确的方式是把filter 放在html 上。 其他拓展 filter 属性还有一些其他有趣的属性值 filter: blur() | brightness() | contrast() | drop-shadow() | grayscale() | hue-rotate() | invert() | opacity() | sepia() | saturate() | url(); // drop-shadow() 给图像设置一个阴影效果filter: drop-shadow(4px 4px 10px blue); filter 属性支持设置多个滤镜,CSS 会根据它们出现的顺序将它们应用于元素。 filter: drop-shadow(4px 4px 10px blue) saturate(99%) brightness(200%); 还有,像播放音乐的毛玻璃背景效果、高斯模糊等也基本可以通过filter 实现。 // invert: 反转输入图像// hue-rotate 给图像应用色相旋转。 "angle"一值设定图像会被调整的色环角度值// 暗黑模式filter: invert(100%) hue-rotate(180deg);

14
life.caoover 3 years

原生 JavaScript 徒手开一个弹幕组件

原生 JavaScript 实现弹幕组件 为什么造轮子? 弹幕的需求很难去做到通用化,一百个产品就有一百种需求,网上的轮子并不符合本次产品的要求,不如自己写一个,假如后续产品需求发生了变更,调整起来也不至于太被动。 先看最终效果 弹幕类设计 在代码实现前,先构思出期望的调用方式: <div id="demo"></div> const barrageIns = new Barrage('#demo', { speed: 100, // 弹幕速度 isInfinite: true, // 是否循环播放 maxTrack: 3, // 轨道数限制,即一次最多播放多少条弹幕 }); // 设置弹幕 const bulletScreenList = [ '叮!新人188元礼包已到账!仅限7天!', '邀请好友用小拉,他打车,你赚钱~', '打车低至1折起,惊喜折扣享不停!', '超值券包一单回本,打工人必买!', '每日限时1分钱秒杀超值折扣券', '邀好友助力领大额折扣券', ]; barrageIns.setItem(bulletScreenList); // 销毁弹幕 barrageIns.destroy(); barrageIns = null; 下面开始实现这个弹幕类。 开始造轮子 工具函数 本次弹幕组件所用到的两个工具函数如下: /** * 生成[min, max]的随机数 */ function getRandomNum(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1) + min); } /** * px转换为vw * @param {number} px * @param {number} [viewportWidth] 设计稿宽度,单位px,默认为375 * @returns {string} vw,例:10vw */ function pxToVw(px: number, viewportWidth?: number) { const vw = `${(px / (viewportWidth || 375)) * 100}vw`; return vw; } 创建弹幕容器 将弹幕统一包裹在一个元素中,便于destroy方法去销毁 DOM。 type BarrageOpt = { }; class Barrage { private targetEl: HTMLElement = document.documentElement; private coverEl: HTMLElement | null = null; private coverH: number = 0; /** 弹幕文案列表 */ private barrageList: string[] = []; constructor(el: string | HTMLElement, opts: BarrageOpt) { this.createCover(el); } private createCover(el: string | HTMLElement) { if (typeof el === 'string') { if (!document.querySelector(el)) { console.error(`[barrage error]无法找到${el}元素`); return; } this.targetEl = document.querySelector(el) as HTMLElement; } else { this.targetEl = el; } this.targetEl.style.position = 'relative'; const cover = document.createElement('div'); cover.id = `barrage-cover__${Math.floor(Math.random() * 100000)}`; cover.style.cssText = ` position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; overflow: hidden; `; this.coverEl = cover; this.targetEl.appendChild(cover); this.coverH = cover.offsetHeight; } public destroy() { if (!this.coverEl) return; this.coverEl.remove(); } } 此时在目标元素poster-wrapper中创建了一个 id 为 `barrage-cover__随机number`的弹幕容器: import Barrage from '../xxxxx'; const barrageIns = new Barrage('#poster-wrapper'); 创建弹幕元素,并使其运动 为元素添加动画,能想到的方法有很多: transition animation requestAnimationFrame setTimeout / setInterval ... 本次采用纯 CSS 的方式来做,其中transition和animation两种方式并无性能差别,但当元素并未销毁,只是转变为不可见状态时,两者有不同的行为,分别使用这两种方式让元素运动: transition animation 在动画过程中,设置diplay: none隐藏元素,当元素再次显示时,使用transition的元素会直接过度到最后一帧,且不会触发transitionend event;而使用animation的元素则会回到第一帧后重新执行动画。 让元素转为不可见状态的被动场景有: 使用了 keep-alive,在当前项目路由内进行页面跳转; iOS 设备中,跳转至任意页面; 在发生了上述被动场景之后,重新返回至原页面时,期望的是弹幕依然能够保持动效,所以采用animation去做动画,通过监听animationend event去回收 DOM。 class Barrage { ... /** 一条弹幕的高度(单位px,设计稿基准为375px) */ private barrageH: number = 28; constructor(el: string | HTMLElement, opts: BarrageOpt) { this.createCover(el); this.createKeyframes(); } private createCover(el: string | HTMLElement) {...} private createKeyframes() { const bodyW = document.body.offsetWidth; const frames = ` @keyframes barrage-left-go { 0% { transform: translate3d(100%, 0, 0); } 100% { transform: translate3d(${0 - bodyW}px, 0, 0) } } `; const style = document.createElement('style'); style.innerHTML = frames; document.getElementsByTagName('head')[0].appendChild(style); } /** * 设置弹幕 * @param {string[]} barrageList 弹幕数组 */ public setItem(barrageList: string[]) { if (barrageList.length < 1) return; this.barrageList = barrageList; barrageList.forEach((item) => { this.createBarrage(item); }); } /** * 创建一条弹幕 * @param {string} text 弹幕文案 * @param {function} cb 弹幕动效结束回调 */ private createBarrage(text: string, cb?: () => void) { const barrageEl = document.createElement('div'); barrageEl.innerHTML = text; barrageEl.style.cssText = ` position: absolute; top: ${getRandomNum(0, 100)}%; right: 0; z-index: 1; pointer-events: none; height: ${pxToVw(this.barrageH)}; line-height: ${pxToVw(this.barrageH)}; font-size: ${pxToVw(14)}; font-family: PingFang SC, PingFang SC-Medium; font-weight: 500; color: #ffffff; padding: 0 ${pxToVw(10)}; background: rgba(0, 0, 0, 0.5); box-sizing: content-box; border-radius: ${pxToVw(28)}; transform: translate3d(100%, 0, 0); `; if (this.coverEl) { this.coverEl.appendChild(barrageEl); barrageEl.style.animation = 'barrage-left-go 5s linear'; barrageEl.addEventListener('animationend', () => { barrageEl.remove(); if (cb) cb(); }); } } } 调用一下试试: import Barrage from '../xxxxx'; const barrageIns = new Barrage('#test'); barrageIns.setItem(['我是弹幕']); 控制弹幕速度 上面的代码中,将animation-duration设为 5 秒,而每条弹幕文案的字符数量不定,生成的 DOM 的宽度也都不同,如果此时有多条宽度不等的弹幕: 而我的期望是每条弹幕的移动速度能够保持一致,显然animation-duration不应该是一个固定的值。 根据弹幕移动的先后位置,不难看出一条弹幕移动的总距离为屏幕宽度 + 弹幕宽度 只需要设定移动速度,就可以得出每条弹幕移动到屏幕之外需要多长时间: type BarrageOpt = { /** 弹幕移动速度(以375px为基准,单位px/s,默认值:100) */ speed?: number; }; class Barrage { ... /** 弹幕速度。单位:px/s */ private speed: number = 100; constructor(el: string | HTMLElement, opts: BarrageOpt) { this.initOpts(opts); this.createCover(el); this.createKeyframes(); } private initOpts(opts: BarrageOpt) { const { speed } = opts; this.speed = speed || this.speed; } private createCover(el: string | HTMLElement) {...} private createKeyframes() {...} /** * 创建一条弹幕 * @param {string} text 弹幕文案 * @param {function} cb 弹幕动效结束回调 */ private createBarrage(text: string, cb?: () => void) { ... if (this.coverEl) { this.coverEl.appendChild(barrageEl); const barrageElW = barrageEl.offsetWidth; const bodyW = document.body.offsetWidth; const totalTime = (barrageElW + bodyW) / this.speed; barrageEl.style.animation = 'barrage-left-go 5s linear'; barrageEl.style.animation = `barrage-left-go ${totalTime}s linear`; ... } } } 改完之后,可以看到不同宽度的 DOM 移动速度都一致了 同时,为了防止一条轨道中有多条弹幕出现,需要给正在使用的轨道“上锁”。在引入了“轨道”概念之后,代码实现如下: class Barrage { ... /** 相邻弹幕Y轴之间的间隔(单位px) */ private barrageOffsetY: number = 0; /** 记录每条轨道中的弹幕 */ private tracks: Record<number, string[]> = {}; /** 轨道数 */ private trackCount: number = 0; constructor(el: string | HTMLElement, opts: BarrageOpt) { this.createCover(el); this.createKeyframes(); } private createCover(el: string | HTMLElement) {...} private createKeyframes() {...} /** * 设置弹幕 * @param {string[]} barrageList 弹幕数组 */ public setItem(barrageList: string[]) { if (barrageList.length < 1) return; this.createTrack(); this.barrageList = barrageList; barrageList.forEach((item) => { this.createBarrage(item); this.push(item); }); } /** * 创建弹幕轨道 */ private createTrack() { this.trackCount = Math.floor(this.coverH / this.barrageH); for (let i = 0; i < this.trackCount; i++) { this.tracks[i] = []; } this.barrageOffsetY = (this.coverH % this.barrageH) / (this.trackCount + 1); } /** * 将弹幕内容放入轨道中 * @param text 弹幕内容 */ public push(text: string) { const freeI = this.getTrackIndex(); this.tracks[freeI].push(text); this.send(freeI); } /** * 获取相对空闲的一条轨道 * 如空闲轨道为多条,则会随机分配 * @returns {number} 轨道的key */ private getTrackIndex(): number { // 每条轨道内装载的弹幕数 const counts = Object.values(this.tracks).map((item) => item.length); const keys = Object.keys(this.tracks).map(Number); // 找出装载最少弹幕的轨道,即“相对空闲” const min = Math.min(...counts); const acc = counts .map((item, index) => { if (item === min) return index; return -1; }) .filter((item) => item >= 0); // 随机分配 const i = acc[getRandomNum(0, acc.length - 1)]; return keys[i >= 0 ? i : 0]; } /** * 发射弹幕 */ private send(i: number) { const text = this.tracks[i][0]; // 给正在运行的弹幕轨道“上锁” if (text === '__lock') { return; } this.tracks[i][0] = '__lock'; this.createBarrage( text, i * this.barrageH + (i + 1) * this.barrageOffsetY, () => { this.tracks[i].shift(); if (this.tracks[i].length > 0) { this.send(i); return; } }, ); } /** * 创建一条弹幕 * @param {string} text 弹幕文案 * @param {number} top 弹幕元素的 top 值,单位px * @param {function} cb 弹幕动效结束回调 */ private createBarrage(text: string, top: number, cb?: () => void) { ... barrageEl.style.cssText = ` ... top: ${getRandomNum(0, 100)}%; top: ${top}px; ... `; ... } } 弹幕错峰 从当前的表现来看,弹幕是“一股脑”的出现的,表现力很僵,而且也不符合需求,所以需要为弹幕增加一个“错峰”出现的效果。 实现起来很简单,只需要给每条弹幕都设置一个随机的animation-delay即可: class Barrage { ... constructor(el: string | HTMLElement, opts: BarrageOpt) { this.createCover(el); this.createKeyframes(); } private createCover(el: string | HTMLElement) {...} private createKeyframes() {...} /** * 创建一条弹幕 * @param {string} text 弹幕文案 * @param {number} top 弹幕元素的 top 值,单位px * @param {function} cb 弹幕动效结束回调 */ private createBarrage(text: string, cb?: () => void) { ... if (this.coverEl) { this.coverEl.appendChild(barrageEl); ... const delayTime = Math.random() * 3; barrageEl.style.animation = 'barrage-left-go ${totalTime}s linear'; barrageEl.style.animation = `barrage-left-go ${totalTime}s ${delayTime}s linear`; ... } } } 改动后,从视觉上弹幕的表现力更灵活了 弹幕并发数限制 上述我们利用弹幕运行区域高度和弹幕高度,设计出了弹幕轨道,但需求文档中有一条限制:一次播放三条弹幕。所以,还需要再对轨道进行处理,去限制弹幕的并发数量。 根据现有代码,这个功能点也很容易实现,只需要删掉多余的轨道,使得弹幕运行区域内只有三条轨道即可: type BarrageOpt = { ... /** 最多允许几条轨道发射弹幕 */ maxTrack?: number; }; class Barrage { ... /** 最多允许几条轨道发射弹幕(0表示不做限制) */ private maxTrack: number = 0; constructor(el: string | HTMLElement, opts: BarrageOpt) { this.createCover(el); this.createKeyframes(); } private initOpts(opts: BarrageOpt) { const { speed, maxTrack } = opts; ... if (maxTrack) { this.maxTrack = maxTrack; } } private createCover(el: string | HTMLElement) {...} private createKeyframes() {...} ... /** * 创建弹幕轨道 */ private createTrack() { this.trackCount = Math.floor(this.coverH / this.barrageH); for (let i = 0; i < this.trackCount; i++) { this.tracks[i] = []; } // 轨道数限制 if (this.maxTrack && this.maxTrack < this.trackCount) { let c = this.trackCount; const trackKeys = Object.keys(this.tracks).map(Number); const l = this.trackCount - this.maxTrack; for (let i = 0; i < l; i++) { const delKey = trackKeys[getRandomNum(0, --c)]; delete this.tracks[delKey]; trackKeys.splice(delKey, 1); } } this.barrageOffsetY = (this.coverH % this.barrageH) / (this.trackCount + 1); } ... } 调用一下试试: import Barrage from '../xxxxx'; const barrageIns = new Barrage('#test', { maxTrack: 3, }); barrageIns.setItem([ '邀请好友用小拉,他打车,你赚钱~', '打车低至1折起,惊喜折扣享不停!', '超值券包一单回本,打工人必买!', '每日限时1分钱秒杀超值折扣券', '邀好友助力领大额折扣券', ]); 循环播放 到现在为止,就还差最后一个功能点了:弹幕列表全部播放完毕之后,从头开始重复播放。只需要在animationend event触发后,判断轨道内是否还有剩余弹幕即可: type BarrageOpt = { ... /** 是否循环播放 */ isInfinite?: boolean; }; class Barrage { ... /** 循环播放 */ private isInfinite: boolean = false; constructor(el: string | HTMLElement, opts: BarrageOpt) { this.createCover(el); this.createKeyframes(); } private initOpts(opts: BarrageOpt) { const { speed, maxTrack, isInfinite } = opts; ... if (isInfinite) this.isInfinite = isInfinite; } private createCover(el: string | HTMLElement) {...} private createKeyframes() {...} ... /** * 设置弹幕 * @param {string[]} barrageList 弹幕数组 */ public setItem(barrageList: string[]) {...} /** * 检查轨道是否全部处于空闲 */ private isTrackAllFree(): boolean { // 每条轨道内装载的弹幕数 const counts = Object.values(this.tracks).map((item) => item.length); return counts.filter((item) => item > 0).length === 0; } /** * 发射弹幕 */ private send(i: number) { ... this.createBarrage( text, i * this.barrageH + (i + 1) * this.barrageOffsetY, () => { this.tracks[i].shift(); if (this.tracks[i].length > 0) { this.send(i); return; } if (this.isInfinite && this.isTrackAllFree()) { // 此时轨道中已经没有弹幕了,在这里处理循环播放逻辑 this.setItem(this.barrageList); } }, ); } ... } 接着运行下面的代码,大功告成~ import Barrage from '../xxxxx'; const barrageIns = new Barrage('#test', { speed: 100, isInfinite: true, maxTrack: 3, }); barrageIns.setItem([ '邀请好友用小拉,他打车,你赚钱~', '打车低至1折起,惊喜折扣享不停!', '超值券包一单回本,打工人必买!', '每日限时1分钱秒杀超值折扣券', '邀好友助力领大额折扣券', ]); 性能探索 上述已经实现了弹幕组件,读者可能会有这类疑问: 动态生成 DOM 会不会导致页面卡顿? 为什么不用 Canvas 来做? 其实上述都绕不开“性能”二字,要回答这些问题,可以借助于 requestAnimationFrame (以下简称为 rAF)去实现性能评测。 由 MDN 文档可知,在浏览器每次重绘之前,会调用 rAF 传入的回调函数,回调函数执行次数通常为每秒 60 次,理论上,帧率越高,动画也就越流畅。通过计算 1 秒中调用了多少次回调函数,就可得出当前的帧率: import { env } from '@/config/env'; type TBoardInstance = { setText: (arg0: string) => void; }; class Board { private boardEl: HTMLElement | null = null; constructor() { this.createDom(); } createDom() { const el = document.createElement('div'); el.id = `fps-board__${Math.floor(Math.random() * 100000)}`; el.style.cssText = ` position: fixed; z-index: 999; top: 0px; left: 0px; padding: 6px 10px; background: rgba(0,0,0,0.4); font-size: 12px; color: white; `; document.body.appendChild(el); el.innerHTML = 'FPS: -'; this.boardEl = el; } setText(text: string) { if (this.boardEl) this.boardEl.innerHTML = text; } } /** * 检测FPS * Tip:会自动在页面左上角生成 DOM 显示当前 FPS。仅限非 prd 环境使用 */ const reportFps = () => { if (env === 'prod') return; let tsTmp = performance.now(); let count = 0; const board: TBoardInstance = new Board(); const loop = (ts: DOMHighResTimeStamp) => { count++; const nowTime = performance.now(); if (nowTime - tsTmp >= 1000) { const fps = Math.round((1000 * count) / (nowTime - tsTmp)); board.setText(`FPS: ${fps}`); tsTmp = ts; count = 0; } window.requestAnimationFrame(loop); }; window.requestAnimationFrame(loop); }; export default (() => { let lock = false; return () => { if (!lock) reportFps(); lock = true; }; })(); reportFps方法计算了每秒的刷新率,并在页面上创建 DOM 作为看板展示,我将此方法绑定在window对象上,这样可在真机上通过vConsole自行运行此方法,来观察实时刷新率。 测试机型为三星GALAXY A9,是 2016.03 发售的中低端机型。可以从下图左上角的看板中发现,帧率能够维持在 60 左右,说明了页面的性能瓶颈没有那么容易达到。 在上述的真机测试中,其实也不免会令人怀疑:这样计算帧率靠谱吗?会不会无论卡顿与否,这样算出来的帧率一直都会是 60 左右呢? 那么就来造一个稍微极端点的掉帧场景,在真机上通过vConsole去执行下面这段测试代码,生成 200 个 DOM 元素,让它们都做一个持续 5 秒的平移动画: for (let i = 0; i < 200; i++) { createEl() } function createEl() { let el = document.createElement('div'); el.style.cssText = ` position: fixed; z-index: 999; top: ${Math.floor(Math.random() * 101)}%; right: 0px; padding: 6px 10px; background: rgba(0,0,0,0.4); font-size: 12px; color: white; transition: 5s; transform: translateX(0); `; document.body.appendChild(el); el.innerHTML = '我是弹幕~~'; el.addEventListener('transitionend', () => { el.remove() }); setTimeout(() => { el.style.transform = `translateX(${0 - document.body.offsetWidth}px)`; }, Math.random() * 2000) } 当大量的 DOM 元素进行动画时,FPS 跌到了 30 以下,页面有明显掉帧,动画结束后,FPS 恢复到原先的 60 左右。可以说,利用 rAF 计算出的帧率是有参考价值的。 总结 本文从需求本身入手,由结果反推过程,对实现思路进行了拆解,逐步去实现了一种基于原生 JavaScript 的弹幕组件,并对后续的性能表现进行了一定的探索,能够有指标评估性能好坏,做到心中有数。

131
life.caoover 3 years

React 中表单的规范和核心

契机 最近一年都在做B端的项目,由于存在着大量的表单场景,做了一段时间后发现 在表单功能上的实现方式都会因人而异,并没有一个统一的规范。 因此,想在这里聊聊关于我对于 React 表单的一些见解。 核心法则 - 单一数据源(Single Source Of Truth) 首先要明确一点,表单中的字段都需要存储在单一的数据源store中。 store中的状态与组件的视图是一一对应的,这样保证了数据的流动是单向的,代码在更易读的同时也更便于之后的维护,如下图所示: 而当我们没有单一数据源的话,只是将状态交由给我们的表单组件维护,就很容易出现父与子、兄与弟、孙与爷之间的数据传输。 在如此混乱的层级结构,其中的逻辑会变得难以理清,从而造成代码腐败。如下图所示: 受控组件(Controlled Component) 即自身内不存在状态(例 useState, useContext, useReducer 等等...)的组件 受控组件是实现单一数据源的基石。只有将组件的状态交由外部数据源(即store)管理,我们才能实现上述的单一数据源原则。表单组件只需要关注状态的输入(value)和变化(onChange),这样可以通过其他组件来更新表单状态,也可以轻松地在其他UI组件之间共享状态。 // good 受控组件 function Input({ value, onChange }) { return ( <input value={value} onChange={function handleChange(ev) { onChange(ev.target.value); }} /> ); } // bad 非受控组件 function Input() { const [value, setValue] = useState() return ( <input value={state} onChange={function handleChange(ev) { setValue(ev.target.value) }} /> ); } 衍生状态(Derived State) 在很多时候对于衍生状态,常常会新增一个状态来维护(例如useState),但实现方式往往是错误的。如下: function Fullname({ firstName, setFirstName, lastName }) { const [fullname, setFullname] = useState(firstName + lastName) return ( <div> <p>{fullname}</p> <input value={firstName} onChange={function handleChange(ev) { setFirstName(ev.target.value); setFullname(ev.target.value + lastName) }} /> </div> ); } 这样做违反了我们之前提到的核心法则 - [单一数据源] 在上面的例子中,我们使用useState来管理fullName状态,但是fullName并不是由firstName和lastName这两个状态派生出来的。如果外部传入的props中的firstName或lastName发生变化,你会发现fullName并不会更新。 为了实现单一数据源,我们应该将fullName作为firstName和lastName的衍生状态,而不是独立维护的状态。我们可以按照以下方式进行修改: function Fullname({ firstName, setFirstName, lastName }) { // fullname 是由 firstName 和 lastName 计算得出的 const fullname = firstName + lastName return ( <div> <p>{fullname}</p> <input value={firstName} onChange={function handleChange(ev) { setFirstName(ev.target.value); setFullname(ev.target.value + lastName) }} /> </div> ); } 性能优化(Performance Optimization) 在上面的设计中,store 中任何微小的变化都会引起整个表单的重渲染。在一些简单的场景下,上面的写法足够好用。但在复杂表单场景下,你就需要对受控组件进行颗粒化更新,而不是全量更新。 最简单的实现方式是使用 React.memo 来减少组件的不必要更新,示例如下: const MemoInput = React.memo(Input) 但是,当需要将 store 中的状态传递到层级较深的组件,并沿着组件树传递时,沿途的组件都会重新渲染,这也会造成性能问题。为了减少这些不必要的渲染,我们可以采用发布订阅的设计模式。 以下是一个发布订阅模式的伪代码示例: function Input({ name }) { const store = useStore() const [value, setValue] = useState(store.get(name)) useEffect(() => { const unsubscribe = store.subscribe(name, newValue => { setValue(newValue) }) return unsubscribe }, [store, name]) return ( <input value={value} onChange={function handleChange(ev) { store.set(name, ev.target.value) }} /> ) } 在上述示例中,Input 组件通过订阅 store 中的 name 实现了只关心当前 name 变化的值。目前主流的表单库的核心原理也大致如此,例如 Antd 表单中的 namePath 和 Formik 中的 fieldName。 通过采用发布订阅模式,我们可以将表单项的更新范围限制在需要更新的组件上,避免了不必要的渲染。这样可以大大提高表单的性能,尤其在复杂表单场景下,更加有效地优化表单的渲染和响应性能。 竞态问题(Race Conditions) 在业务中,我们经常遇到这样的情况:我们需要通过异步请求获取当前用户的信息,并将其作为表单的默认值。通常的做法是先渲染表单,然后在数据加载完成后重新填充表单。 这种情况下可能会出现竞态问题。当用户正在填写表单时,如果在数据加载完成前重新填充表单字段,之前用户填写的内容就会丢失,用户体验将变得糟糕。 为了避免这种情况,我们需要确保在异步请求完成之前不允许修改表单,例如: function UserForm() { const { user, isLoading } = useUser() if(isLoading) return "loading" return ( <Form initialValues={user}> {...} </Form> ); } 最后 /* * _oo0oo_ * o8888888o * 88" . "88 * (| -_- |) * 0\ = /0 * ___/`---'\___ * .' \\| |// '. * / \\||| : |||// \ * / _||||| -:- |||||- \ * | | \\\ - /// | | * | \_| ''\---/'' |_/ | * \ .-\__ '-' ___/-. / * ___'. .' /--.--\ `. .'___ * ."" '< `.___\_<|>_/___.' >' "". * | | : `- \`.;`\ _ /`;.`/ - ` : | | * \ \ `_. \_ __\ /__ _/ .-` / / * =====`-.____`.___ \_____/___.-`___.-'===== * `=---=' * * * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ * * 佛祖保佑 永不宕机 永无BUG */

81
life.caoover 3 years

自定义 hook 实现 React 状态管理

一、背景 最近在写组件库 demo 文档的时候,希望能实现动态主题切换实时预览功能。预期效果如下: 右上角 ThemeSwitch 组件在更改内部主题色状态 color 时,希望实时更新左上角标题内容及主题色、iframe 组件内部组件主题色。 二、能简单实现吗? 上面的场景其实就是解决多个组件内部状态同步的问题,我相信大家第一反应肯定是上 Redux、Store 这种状态管理库,相关文档及如何使用大家都很熟悉, 但是这里有两个问题值得我们思考: 我只是想同步个状态,能简单实现不引入第三方类库么? 状态管理类库内部是如何实现的,原理是什么? 三、问题分析 有问题,先分析问题。分析问题的思路有很多,大致可以分为两种: 0 - 1 :先干,把代码跑起来,一步步遇到问题,解决问题;合适解题路径较清晰问题; 1 - 0 :从结果出发,分析要想达到该结果的前置条件,追本溯源;适合拆解复杂问题; 这里我们采用 1 - 0 的方法来分析: 通知同步 要想达到状态同步,当主题切换组件 ThemeSwitch 改变内部主题状态,执行 setColor(newColor)时,我们只要 通知 其他组件执行同样 setColor(newColor) 操作更新自己的状态就行,这样就达到了状态同步目的: 如何通知? 通知其实就是执行各个组件内部的 setColor 方法,这里就需要将 setColor 收集 起来,然后在需要同步的时候依次调用,结合上步的 通知,我们很容易想到这是一个 发布-订阅 模型。各组件在声明内部状态 useState() 时,是个很好的订阅时机,我们可以自定义一个 hooks useColorState 劫持该操作, 作为 Controller, 维护 订阅-发布 功能: 四、解决 通过分析,我们得到了解决状态同步的基本思路: 我们需要一个自定义的 hook 去拦截各子组件内部状态的定义,以便绑定订阅关系,留下操作空间;自定义 hook 需要实现发布订阅功能,在状态变更时通知各组件; 自定义 hooks // useColorState.js export default function useColorState() { const [color, setColor] = useState(); return [color, setColor]; } 一个简单的 发布-订阅 模型 class ColorChanger { constructor() { this.color = 'orange'; this.callBacks = []; } // 订阅 listen(cb) { this.callBacks.push(cb); } // 发布 publish() { this.callBacks.forEach(cb => cb(this.color)); } } 订阅 完成订阅和内部状态初始化: let colorChanger; export default function useColorState() { const [color, setColor] = useState(); useEffect(() => { colorChanger = colorChanger || new ColorChanger(); colorChanger.listen(setColor); setColor(colorChanger.color); }, []) return [color, setColor]; } 发布 这里 hook 返回只是更新自己状态的 setColor, 我们需要通知其他组件: function useColorState() { const [color, setColor] = useState(); const changeColor = val => { colorChanger.setColor(val) } // ... return [color, changeColor]; } class ColorChanger { // ... setColor(color) { this.color = color; this.publish(); } } 取消订阅 当组件卸载时,我们需要取消订阅,将该组件的 setColor 从 colorChanger.callbacks 中删除即可: class ColorChanger { // ... unlisten(cb) { this.callBacks.splice(this.callBacks.indexOf(cb), 1); } // ... } function useColorState() { useEffect(() => { // ... return () => { colorChanger.unlisten(setColor) }; }, []) } 五、还要 到这里,通过自定义 hook 和 发布-订阅模式,我们实现了一个简单的状态同步功能,但是依然有一些问题在 debug 时被发现了: 小优化 在 setColor(sameColor) 时,依然触发了发布,而发布是一个高成本操作,所以这里需要优化: class ColorChanger { // ... setColor(color) { if (this.color === color) return; this.color = color; this.publish(); } } Reducer 优化让人上瘾,我们这里只是维护了一个 string 类型的简单状态 color,Redux store 一般都是拥有复杂深度的 Object,那 Redux 要怎么对比新老状态是否变更了呢? 答案是 Reducer,以下是一个 Reducer 的定义: const newState = reducer(oldState, action); 所以我们也改造下 useColorState: function colorReducer(state = {color: 'orange'}, action) { return action.color === state.color ? state : {color: action.color}; } export default function useColorState(colorReducer) { // ... colorChanger = colorChanger || new ColorChanger(colorReducer); // ... } class ColorChanger { // ... setColor(action) { const newState = this.colorReducer(this.state, action); if(this.state === newState)) return; this.state = newState; this.publish(); } } Redux Reducer 设计避免了每次需要深度遍历比较新旧状态,而是通过 reducer 判断没更新就返回老对象,这样我们就能直接 === 比较对象地址,约定有时比直接解决问题有效。 六、结尾 到这里基本结束了,当然肯定还有很多需要优化的点: 比如 changeColor 应该使用 useCallback 包裹防止 setColor 作为 props 传递产生的不必要的渲染、比如发布的时候应该做个队列确保本轮通知完成再进行下轮状态变更... 本文只是通过实际问题提供一个思考过程: 很多第三方类库看起来代码量很多 ,技术原理可能并没有想象中那么复杂,多数代码是为了应对各种边界情况、性能优化、支持同步异步等做了扩展,有的库甚至为了追求大而全堆了一些和核心能力不相关的代码,造成我们阅读源码起来不是很顺畅。所以只有分析了问题的核心,了解了基础的技术思路,我们使用起来才会更得心应手。

6
life.caoover 3 years

Hooks中使用MVC思想

引言 怎么延缓代码结构熵增的过程? 为什么基于视图开发会给后续迭代埋坑? Hooks 我们常听说,那可以怎么去写? 遇到的问题 前端发展到了今天,伴随着日益复杂的交互,越来越多的逻辑放到前端进行实现。 在迅速膨胀的业务场景中,我们会发现,代码在迭代的过程中越发地难以维护。在一些迭代频率比较高的场景,短短几个版本的时间内,代码就会变得难以阅读和理解。想知道某个页面元素在什么情况下显示,从判断条件 a 看到 变量 b,再跳到文件 c,好一番折腾才能知道这里面的逻辑。 那这一系列问题的原因出在哪呢? 一、业务场景本身就具有较高的复杂度。对需求涉及的场景全局掌握,需要的不仅是知识的广度,还有对业务了解的深度。不能全局的掌握所有场景,在设计初期有纰漏是在所难免的事情。 二、技术模型满足不了业务的膨胀。前端开发的过程中,我们常常是「视图驱动」的,拿到一个需求后,根据业务需求提炼出一个技术模型,然后围绕它进行编码开发。但是用户的需求不可能被一个既定的技术模型所概括,业务的改动和膨胀都极易对这个已有的模型造成致命的破坏。 三、知识的丢失。同一场景不同同事介入开发、负责模块的同事离职、跨项目支持、注释的不完善等等,代码虽然在仓库中保存下来了,但是知识——代码背后的逻辑、设计的出发点和考量多多少少都会出现丢失。 之前是怎么做的 前面的废话有点多,我们接下来直入正题。 首先,假定我们有一个需求,实现这么一个表单: 具体交互 提交揽收、干线、派送三段的费用,然后选中优惠券之后可以给指定某一段的费用打折(业务逻辑) 提交的时候,如果有优惠券,但是用户没有选,弹窗提醒用户(交互逻辑) 我们拿到需求之后,一般都是基于 UI 稿,从页面出发,先考虑怎么实现界面,然后再根据产品的 PRD,把交互逻辑做成一些判断条件,去决定某些部分是否展示。但是这样「视图驱动开发」做法是有问题的,具体什么问题我们看完代码后分析。 <script> export default { data() { return { form: { pickupFee: undefined, zxFee: undefined, deliveryFee: undefined, couponId: undefined, }, isUseCoupon: false, activatedCoupon: null, couponList: [] } }, computed: { total() { const { activatedCoupon, form, isUseCoupon } = this const { pickupFee, zxFee, deliveryFee } = form if (!isUseCoupon || !form.couponId) { return pickupFee + zxFee + deliveryFee } else { const map = { pickup: pickupFee, zx: zxFee, delivery: deliveryFee } return Object.keys(map).reduce((result, feeType) => { const originalFee = +map[feeType] || 0 const subFee = feeType === activatedCoupon.type ? originalFee * activatedCoupon.discount : originalFee return result + subFee }, 0) } }, }, mounted() { // 模拟请求优惠券列表 this.couponList = [ { id: 1, name: '揽收费专享', type: 'pickup', discount: 0.8 }, { id: 2, name: '干线费专享', type: 'zx', discount: 0.64 }, { id: 3, name: '派送费专享', type: 'delivery', discount: 0.12 } ] }, methods: { setActivatedCoupon(selectedId) { this.activatedCoupon = this.couponList.find(item => item.id === selectedId) }, onSubmit() { // 一些校验逻辑 const { couponList, isUseCoupon, form } = this if (!!couponList.length && (!isUseCoupon || !form.id)) { alert('您有优惠券,确定不使用么') } console.log('submit!'); } }, } </script> <template> <el-form ref="form" :model="form" label-width="110px"> <el-form-item label="揽收费"> <el-input v-model="form.pickupFee"></el-input> </el-form-item> <el-form-item label="干线费"> <el-input v-model="form.zxFee"></el-input> </el-form-item> <el-form-item label="派送费"> <el-input v-model="form.deliveryFee"></el-input> </el-form-item> <el-form-item label="是否使用优惠券"> <el-switch v-model="isUseCoupon"></el-switch> </el-form-item> <template v-if="isUseCoupon"> <el-form-item label="优惠券列表" > <el-select v-model="form.couponId" placeholder="请选择优惠券" @change="setActivatedCoupon"> <el-option v-for="item in couponList" :key="item.id" :label="item.name" :value="item.id" ></el-option> </el-select> </el-form-item> <el-form-item label="折扣" > <span>{{ form.id ? activatedCoupon.discount : '未选中优惠券' }}</span> </el-form-item> </template> <el-form-item label="总价"> <span>¥:{{ total }}元</span> </el-form-item> <el-form-item> <el-button type="primary" @click="onSubmit">提交</el-button> <el-button>取消</el-button> </el-form-item> </el-form> </template> 现在我们来回头看,「视图驱动开发」有什么问题?我们把目光投向 computed: 在实现中,activatedCoupon依赖于isUseCoupon和form.id去计算。它们彼此之间有无形的线牵扯着,计价的逻辑就变得不纯粹了。假如有另外一个计价模式完全一样的场景,不过页面和交互上没有「是否使用优惠券」这个限制。我们尽管有完全一样的计价逻辑,也没办法复制这段代码过去直接使用,因为它和某个需求中特有的交互逻辑耦合在了一起。 这只不过是一个复杂度很小 demo,纠缠在一起的代码不过数行。但事实上,随着业务增长,一个中等规模的组件,字段的数量轻松地就能达到 20 个以上,字段与字段之间,computed,watch,mixin,彼此依赖,互相纠缠,有形的、无形的线错综复杂地交错在一起。 到那时,要想知道某个文字 / 列表 / 图表 等组件在什么条件下才显示,就只能从这一团乱麻中一根一根地捋了。 是从哪一步开始,原本有条不紊的线乱作了一团? 归根结底,是因为业务逻辑和视图逻辑耦合在一起。 是否使用优惠券、是否选中优惠券 、有优惠券却不使用在提交时候需要提示。 这些都是交互逻辑,但是却和计价的业务逻辑耦合在了一起。随着业务场景的膨胀,它们会耦合得更加密切,导致在一定量级之后,我们开发变得如履薄冰: 原本的代码量级很大,复杂度很高。根本不敢去动原来的历史代码,只能在这此基础上添加自己的逻辑。这样就很可能导致逻辑冗余,或者隐性的逻辑冲突。 原本的代码耦合度太高,没办法复用,但是重新去开发需要大量的时间。只能 cv 一份,在备份上修修改改,满足新需求。一些本不属于新需求的逻辑,如果没删干净,就会给后续迭代和代码阅读埋下巨大的隐患。如果删改得不恰当,导致在一些边际情况中代码无法正常运行,更是如堕深坑。 可以怎样优化 我们可以应用 MVC 的理念对逻辑进行解耦,来优化我们的代码。 首先定义一个model,描述该业务逻辑中用到的所有字段: // order-model.js export default class Order { pickupFee = undefined zxFee = undefined deliveryFee = undefined couponId = undefined total = 0 } 然后提供操作model的service,里面封装的就是我们前文说到的「业务逻辑」: // order-service.js function countFeeByCoupon (feeObj, coupon) { const fee = +feeObj.fee || 0 if (!coupon) return fee const discount = coupon && coupon.type === feeObj.type ? coupon.discount : 1 return fee * discount } export default class OrderService { static countTotal(order, coupon) { const { pickupFee, zxFee, deliveryFee } = order const arr = [ { fee: pickupFee, type: 'pickup', }, { fee: zxFee, type: 'zx', }, { fee: deliveryFee, type: 'delivery', } ] order.total = arr.reduce((acc, cur) => { return acc += countFeeByCoupon(cur, coupon) }, 0) } } 接着是contorller,将原本「业务逻辑」和「视图逻辑」耦合的部分抽离至此,提供给视图层: // order-controller.js import Order from './order-model' import OrderService from './order-service' export default class OrderController { model = new Order() isUseCoupon = false couponList = [] activatedCoupon = undefined initCouponList() { this.couponList = [ { id: 1, name: '揽收费专享', type: 'pickup', discount: 0.8 }, { id: 2, name: '干线费专享', type: 'zx', discount: 0.64 }, { id: 3, name: '派送费专享', type: 'delivery', discount: 0.12 } ] } countTotal() { OrderService.countTotal(this.model, this.activatedCoupon) } setActivatedCoupon() { const { couponList, isUseCoupon } = this if (isUseCoupon) { this.activatedCoupon = couponList.find(item => item.id === this.model.couponId) } else { this.activatedCoupon = null } this.countTotal() } submit() { // 一些校验逻辑 if (this.couponList.length && !this.activatedCoupon) { alert('您有优惠券,确定不使用么') } console.log('submit!'); } } 最后就是利用controller去渲染最后的视图层了: <script> import OrderController from './order-controller'; export default { data() { return { controller: undefined, } }, created() { this.controller = new OrderController() this.controller.initCouponList() }, watch: { 'controller.model': { deep: true, handler() { this.controller.countTotal() } }, 'controller.isUseCoupon'() { this.controller.setActivatedCoupon() } }, methods: { setActivatedCoupon() { this.controller.setActivatedCoupon() }, doSubmit() { this.controller.submit() } } } </script> <template> <el-form ref="form" :model="controller.model" label-width="160px"> <el-form-item label="揽收费"> <el-input v-model="controller.model.pickupFee"></el-input> </el-form-item> <el-form-item label="干线费"> <el-input v-model="controller.model.zxFee"></el-input> </el-form-item> <el-form-item label="派送费"> <el-input v-model="controller.model.deliveryFee"></el-input> </el-form-item> <el-form-item label="是否使用优惠券"> <el-switch v-model="controller.isUseCoupon"></el-switch> </el-form-item> <template v-if="controller.isUseCoupon"> <el-form-item label="优惠券列表" > <el-select v-model="controller.model.couponId" placeholder="请选择优惠券" @change="setActivatedCoupon"> <el-option v-for="item in controller.couponList" :key="item.id" :label="item.name" :value="item.id" ></el-option> </el-select> </el-form-item> <el-form-item label="折扣" > <span>{{ controller.activatedCoupon ? controller.activatedCoupon.discount : '未选中优惠券' }}</span> </el-form-item> </template> <el-form-item label="总价"> <span>¥:{{ controller.model.total }}元</span> </el-form-item> <el-form-item> <el-button type="primary" @click="doSubmit">提交</el-button> <el-button>取消</el-button> </el-form-item> </el-form> </template> 改造完之后,视图层中没有业务逻辑的丝毫痕迹,阅读起来很清爽,更便于快速地掌握需求的场景。 虽然总体逻辑被抽成了view->controller->service->model的多层结构,结构变得更复杂,代码量更多了,但是换来了是更高可阅读性和可维护性。 此外,业务逻辑被纯粹地保存在service中,便于复用和迭代。后续开发新的业务逻辑,在制定的service中继续添加新功能即可;如果有类似的需求,也可以很大限度的复用这些代码,由于它是无状态的,所以它可以无缝地接入到任意的项目,任意一种框架。 思考 相信大家看完优化代码之后,心里会有一些疑惑: 视图层和controller有必要分开写么,直接把controller的逻辑放在视图层里面不也可以么? 如何拆解,怎么划分业务逻辑和视图逻辑,之间的边界如何定义? 每个需求都适合这种拆解么,都划分成 MVC 三层,看起来也太麻烦了,有必要这样封装么? 的确,上面视图层和controller分开写,只不过是为了很清晰地展示 MVC 理念在逻辑解耦中的应用。实际开发中我们完全没必要分开写,徒增额外的逻辑。 至于业务逻辑和视图逻辑的划分,不同的视角,会有不同的划分结果,这里面肯定没有一个确切的标准。我们可以尝试从用户角度出发,把用户交互引起的页面变动定义为视图逻辑,页面变动所依赖的数据实体变更定义为业务逻辑。这只是万千方法中的一种,仅供参考。 需要注意的是,这个标准在团队内部需要保持一致,不然大家从不同角度进行逻辑解耦,得到的service不能在不同的场景中复用,那所有的努力也就没有意义了。 最后就是意义。我们回头看问题的症结:业务逻辑和视图逻辑耦合。 拆分成 MVC 三层只不过是其中的一个办法。像是 Vue3 composition API 和 React 的 Hooks 其实都在鼓励我们把纯粹的、无状态的业务逻辑从视图逻辑中抽离出来。MVC 的model和service部分如果合并在一起,和封装 Hooks 的最终形态非常类似。而在具体的组件中引入useXXX的 Hooks 进行渲染,和在视图层中通过controller调用数据实体的service不也一样么。 // use-coupon.js function countFeeByCoupon (feeObj, coupon) { const fee = +feeObj.fee || 0 if (!coupon) return fee const discount = coupon && coupon.type === feeObj.type ? coupon.discount : 1 return fee * discount } export default function (order, coupon) { const { pickupFee, zxFee, deliveryFee } = order const arr = [ { fee: pickupFee, type: 'pickup', }, { fee: zxFee, type: 'zx', }, { fee: deliveryFee, type: 'delivery', } ] return arr.reduce((acc, cur) => { return acc += countFeeByCoupon(cur, coupon) }, 0) } <script setup> import { reactive, watch } from 'vue'; import useCoupon from './use-coupon' const state = reactive({ isUseCoupon: false, activatedCoupon: undefined, }) const form = reactive({ pickupFee: undefined, zxFee: undefined, deliveryFee: undefined, couponId: undefined, total: 0 }) const couponList = reactive([ { id: 1, name: '揽收费专享', type: 'pickup', discount: 0.8 }, { id: 2, name: '干线费专享', type: 'zx', discount: 0.64 }, { id: 3, name: '派送费专享', type: 'delivery', discount: 0.12 } ]) function countTotal() { form.total = useCoupon(form, state.activatedCoupon) } function setActivatedCoupon() { if (state.isUseCoupon) { state.activatedCoupon = couponList.find(item => item.id === form.couponId) } else { state.activatedCoupon = null } countTotal() } watch(form, () => { countTotal() }) watch(() => state.isUseCoupon, () => { setActivatedCoupon() }) function submit() { if (couponList.length && !state.activatedCoupon) { alert('您有优惠券,确定不使用么') } console.log('submit!'); } </script> <template> <el-form :model="form" label-width="110px"> <el-form-item label="揽收费"> <el-input v-model="form.pickupFee"></el-input> </el-form-item> <el-form-item label="干线费"> <el-input v-model="form.zxFee"></el-input> </el-form-item> <el-form-item label="派送费"> <el-input v-model="form.deliveryFee"></el-input> </el-form-item> <el-form-item label="是否使用优惠券"> <el-switch v-model="state.isUseCoupon"></el-switch> </el-form-item> <template v-if="state.isUseCoupon"> <el-form-item label="优惠券列表" > <el-select v-model="form.couponId" placeholder="请选择优惠券" @change="setActivatedCoupon"> <el-option v-for="item in couponList" :key="item.id" :label="item.name" :value="item.id" ></el-option> </el-select> </el-form-item> <el-form-item label="折扣" > <span>{{ state.activatedCoupon ? state.activatedCoupon.discount : '未选中优惠券' }}</span> </el-form-item> </template> <el-form-item label="总价"> <span>¥:{{ form.total }}元</span> </el-form-item> <el-form-item> <el-button type="primary" @click="submit">提交</el-button> <el-button>取消</el-button> </el-form-item> </el-form> </template> 对于前端来说,MVC 的结构还是有些「重」了,实际应用上 Hooks 的形式会更加的适合。而且除了业务逻辑和视图逻辑可以通过 Hooks 进行解耦。由于前端重交互的特性,复杂或者高频的视图逻辑也可以封装成可复用的 Hooks。比如说组件全屏、鼠标点击/拖拽、分页等等。 最后 在前端 Hooks 时代到来之际,我们常常在想:怎么去写高质量、可复用的 组件或者说怎么封装 Hooks 更加优雅。以下几点或许可以参考一二: 在业务场景比较复杂时,尽量不要以「视图驱动」的方式(面向过程)开发; 将用户交互引起的页面变动定义为视图逻辑,页面变动所依赖的数据实体变更定义为业务逻辑; 业务逻辑和视图逻辑解耦,将业务逻辑封装成 Hooks; 将复杂的或高频的视图逻辑封装成 Hooks; 封装 Hooks 的时候结合 MVC 的理念:先找到需要暴露给外部进行消费的数据实体,然后定义实体的model部分,一般体现在利用入参进行实体的创建和初始化;然后将需要运算(并不局限于数学计算,dom 结构的变更,数据结构的变更,局部/全局状态的变更都属于运算)的部分定义成service暴露出去。

12
life.caoalmost 4 years

React 异步场景和一致性

背景 相信大家知道 React 提出的一个核心理念: fn(state) = UI,只要输入是确定的,输出就是确定的。React 把函数式编程的思想带进了前端中。它解决了当时前端开发复杂化的痛点,这毫无疑问是革命性的,React 也因此而名声大噪。 但在 fn(state) = UI 中的 组件(即fn) 只能执行同步的代码,没有异步的概念,React 中不存在这样的公式: async fn(state) = UI 。这将会导致下面的一些问题 异步时序问题 在 React 中,我们请求数据最基本的方法是在组件第一次装载时使用 useEffect 和 浏览器的fetch api,如下: function useRequest(queryFn, deps) { const [data, setData] = React.useState() // 定义一个state用于存储数据 const [isLoading, setIsLoading] = React.useState(false) // 定义一个state用于表示是否正在加载中 const run = () => { // 定义一个函数用于发起请求 setIsLoading(true) // 开始时设置加载状态为true setData(undefined) // 开始时将数据设置为undefined queryFn(...args) .then((data) => { setData(data) // 将返回的数据设置到state中 }) .catch(() => { setData(undefined) // 如果出现错误,将数据设置为undefined }) .finally(() => { setIsLoading(false) // 无论请求成功还是失败,都将加载状态设置为false }) } React.useEffect(() => { run() // 在组件挂载时发起请求 }, deps) return { data, isLoading, run } // 返回数据、状态和一个用于重新发起请求的函数 } 上述代码运行效果如下: 在上述的场景中,我们常常会碰到的一个问题,当我们频繁修改参数时,查询结果显示抖动,且由于 run 方法 是一个异步函数,它执行结束的时间是不可控的。 如果 useEffect 依赖的 deps 在短时间内频繁变化时或频繁手动调用 run 方法时,很容易出现请求的竞态问题,即我们不能保证 data 是哪个 deps 请求的结果。 ps:我们常常用到的库例如 redux、useRequest 都存在着这个竞态问题。 这在一些前端计算场景,尤其是涉及到和钱相关的,这一点特别需要注意。 我们大多数项目都存在这个场景,特别是一些涉及表单筛选查询场景尤为明显。 让我们试着解决 有的小伙伴可能就想到了,当依赖(deps) 变化时取消之前的请求,只当最后一个的请求才生效不就好了么?我们将上面代码改动如下: // 将请求函数包装为可取消的Promise对象 const makeCancelable = (promise) => {...} function useRequest(queryFn, deps) { const [data, setData] = React.useState() const [isLoading, setIsLoading] = React.useState(false) const run = () => { setIsLoading(true) setData(undefined) const wrappedPromise = makeCancelable(queryFn()) // 使得Promise可以被取消 wrappedPromise .then((data) => { setData(data) }) .catch(() => { setData(undefined) }) .finally(() => { setIsLoading(false) }) return wrappedPromise.cancel } React.useEffect(() => { const cancel = run() return () => cancel() // 在组件卸载时取消请求 }, deps) return { data, isLoading, run } } 但还存在着一个问题,那就是当我们直接调用 run 方法时还是会导致上述的时序问题。 所以我们需要将 run 方法替换成纯的、无副作用的 **refetch(不带参数且不会造成竞态)**方法。 但此时 useRequest 只剩下查询能力不涉及增删改等包含突变的能力,所以我们将 useRequest 重命名为更符合语义的名字 useQuery : function useQuery(queryFn, deps) { const [data, setData] = React.useState() const [isLoading, setIsLoading] = React.useState(false) const run = () => { setIsLoading(true) setData(undefined) const wrappedPromise = makeCancelable(queryFn()) wrappedPromise .then((data) => { setData(data) }) .catch(() => { setData(undefined) }) .finally(() => { setIsLoading(false) }) return wrappedPromise.cancel } React.useEffect(() => { const cancel = run() return () => cancel() }, deps) // 将run方法替换为不带参数且不会造成竞态的refetch方法 const refetch = () => { if (!isLoading) { run() } } return { data, isLoading, refetch } } 现在,我们已经可以做到 setData 只会被一个 run 函数调用。流程图如下: 上述代码运行效果如下: 在一些简单场景,这样做是可行的,但是当需要缓存数据和去除重复请求时就变得越来越困难。 例如当参数一致时,当我们点击重试按钮,所有具有相同参数的组件都应当重新查询,而且目前多个组件之间数据是不共享的。 下面我们来讲一下如何进行缓存和去重。 缓存和去重 经过我们修改,已经可以通过 deps(由于缓存需要将deps作为key,下面已将deps重命名为queryKey) 感知到 queryFn 查询函数的参数变化。 接下来利用我们前面提到的函数式编程思想,当输入一致时,返回之前的缓存。 我们可以利用传入的 参数(queryKey) 作为缓存key,如果重复传入相同的 queryKey 时可以从缓存中取出之前的数据,相关实现如下,让我们先来看看代码吧: const queries = {} // 存储所有的查询实例 function getQuery(queryKey, queryFn) { const hash = JSON.stringify(queryKey) // 当 queryKey 一致时,返回已经生成过的查询实例 if (queries[hash]) return queries[hash] queries[hash] = { isLoading: false, data: undefined, // 存放着当前queryKey所有注册过的监听函数。 listeners: new Set(), refetch() { // 去除重复请求 if (this.isLoading) return // 更新状态 this.data = undefined this.isLoading = true // 通知各个hook已经开始进行请求了 this.listeners.forEach((cb) => cb()) queryFn() .then((data) => { this.data = data }) .catch(() => { this.data = undefined }) .finally(() => { this.isLoading = false // 通知各个hook的监听函数更新状态 this.listeners.forEach((cb) => cb()) }) }, } queries[hash].refetch = queries[hash].refetch.bind(queries[hash]) return queries[hash] } function useQuery(queryKey, queryFn) { const [, forceUpdate] = React.useReducer((num) => num + 1, 0) // 调用 getQuery 根据 queryKey 获取当前查询实例 const currentQuery = getQuery(queryKey, queryFn) React.useEffect(() => { currentQuery.refetch() currentQuery.listeners.add(forceUpdate) // 注册监听函数 return () => currentQuery.listeners.delete(forceUpdate) // 删除监听函数 }, [currentQuery]) return { data: currentQuery.data, isLoading: currentQuery.isLoading, refetch: currentQuery.refetch, } } 我们要明确一点,如果我们想要发起另一个请求,只允许改变传入的 queryKey 到 useQuery 中,这样我们才能感知到输入的变化。 下面我们可以看看上述代码的实际运行效果: 去重:当参数一致时,去除重复查询,可以看到点击下面重试按钮,参数相同的组件只有一个请求。 缓存:当参数一致时,所有具有相同参数的组件都会都会复用一份缓存。 无副作用:不管我们点击修改参数多么频繁,实际查询的结果都是当前参数的结果。 现在我们在 react 中可以做到查询之间互不影响且没有交集,只要参数(queryKey)一致结果(query)就一致的纯函数了: 请求库选型 当然,上面只是一个简单的实现,如果你要在开发时避免上述的这些问题,在这里给大家推荐 ReactQuery 和 SWR 这两个React请求库,可以简化我们从服务器获取、缓存和同步数据方式。 上述示例代码,其实就是我们对ReactQuery、SWR 的底层逻辑的实现。 这两个库除了解决这些问题之外还考虑到了几乎所有的场景,而且做得非常好,甚至可以消除你对全局状态管理解决方案的需求。 题外话 在 React 10月13的 RFC: First class support for promises and async/await 中介绍了一个新的api use,主要是着手解决之前说的异步组件问题 async fn(state) = UI ,感兴趣的话了解一下 相关文档 SWR https://github.com/vercel/swr RFC: First class support for promises and async/await https://github.com/reactjs/rfcs/pull/229

16
life.caoover 3 years

分支开发规范(可结合自己团队稍作修改)

分支开发规范 1.开发公约 以 release 分支,作为仓库的主分支 开发需求的分支,始于 release(从 release 拉新分支),终于 release(合入 release) 提交 Merge Request 后,首先要保证自己 Code Reivew 自己的代码 需求发布后,建立当前日期的Tag(版本经理或负责人操作) 2.分支命名规范 友情提示:不要在公共分支上直接写代码,万一被公共分支被覆盖了,你的代码会丢(找回代码麻烦) 开发阶段 环境名称 使用人员 分支命名规范 本地开发 feature/姓名_需求名_开始日期 (日期为MMDD) 开发联调功能测试 STG 开发同学测试同学 feature/姓名_需求名_开始日期 或 stg如有多人同时开发同一个项目,请将自己分支合并到 stg 公共分支 集成测试回归测试 PRE 测试同学产品/业务同学验收 feature/姓名_需求名_开始日期 或 pre如有多人同时开发同一个项目,请将自己分支合并到 pre 公共分支待测试完毕,在发布日当天再将 feature 分支合并到 release,并将 release 分支发布到 PRE 环境,用于发布前最后的回归测试 发布 PRD 统一使用 release 分支发布上线 3.开发新需求 git checkout release git pull origin release git checkout -b feature/yining_xxxx_0310 4.线上需求发布 a) 将自己的 feature 分支 rebase release(强烈建议要,后续如果回滚代码也不容易出错) b) 在 gitlab 上提 feature => release 分支的 Merge Request(无论是否 Code Reivew,都走 MR) c) 晚上发布窗口时,将 release 分支发布到 PRD 环境 Q:为什么要做 rebase release 操作呢? A:这样一来,每次提的 Merge Request 里就不会带上别人的 commit。而 merge release => 你的分支,会将别人的 commit 带到你的 Merge Request 里,不利于比较代码 diff。 更多参考:对 rebase 的详细介绍5.线上问题 hotfix git checkout release git pull origin release git checkout -b hotfix/yining_xxxx_0310 与开发新需求的流程一致,区别只在于开发、调试的分支名为 hotfix/姓名_需求名_开始日期

221
life.caoover 3 years

生产发布SOP

发布SOP 原则须知 原则上,高峰期不做任何操作,包括:发代码、配置变更、灰度放量、开城规则变更,以及在微信公众平台上的操作(小程序放量、request / webview 域名配置、公众号安全域名变更等) 如果有特殊情况,必须在高峰期变更,请说明原因并经过 leader 同意后才可操作 H5 与后端同一天晚上发布,小程序在后端发布后的次日晚上发布(审核原因) toC 小程序的发布,严禁直接全量上架,至少经过3批(可根据自己用户量决定)灰度放量 此篇的目的 规范发布操作流程,避免红线行为 识别前端发布成功的标准,减少不必要的陪跑 具备 oncall 意识,取代低效的等待方式 发布操作流程 知道如何线上验证,无法线上验证的需求务必拒绝发布 明确发布的依赖顺序 可以发布时,将 pre 环境最后一次验证的分支,发布到生产环境 线上验证,一定要线上验证 要看监控 线上验证的标准 如果有测试同学:完整流程可以交给测试去验,我们只要自己看下页面能不能打开 如果是开发自测:必须要在真实APP上完整走一遍主流程,端内的页面不能用浏览器模拟器代替验证 看监控的标准 核心项目,发布完后必须要观察 15min 监控(看各自团队的监控基础设施比如埋点、监控日志) 非核心项目,不做特殊要求,但必须线上验证 红线行为 发布后没有线上验证的,不管什么原因,一律违反SOP。 核心项目发布后不看监控的,如果事后能从监控中发现异常,也属于违反SOP。 发布后当晚有人反馈问题,而不去处理的,也是违反SOP。“处理”行为包括不限于:回滚、hotfix、降级,或沟通其他办法都算 “处理”并非一定变更生产环境,底线是有人反馈问题时,发布人要能应急响应 开发计划里搭车,最后翻车的 如何 oncall 手机畅通,不静音 电脑 & VPN

164
life.caoover 3 years

Module Federation 在H5项目的实践

Module Federation 在H5项目的实践 背景 由于之前在开辟新业务时,组内需要短时间内开发6个中后台系统,这些系统 UI , UX 设计大体一致,前后端均由相同人员合作开发,开发前预想应有大量的模块是可以复用的,故需调研方便模块复用的前端代码管理方案,最终选取了 Webpack5 的最新特性 module federation(下文中简写为 MF ),并在项目实践落地。 介绍 MF 是 webpack5 带来的新特性之一,是一种新的用于在应用程序之间共享代码的解决方案,并解决了共享模块的依赖共享问题。在 MF 中,每一个 webpack 构建都可以是 host (模块消费方)、 remote (模块提供方),也可以同时是模块消费方与模块提供方,并可以通过简单的配置,实现远程模块的依赖共享, 而且可以加速项目的构建(配置共享依赖后 webpack 会将共享依赖包作为异步 chunk 处理加速项目构建)。 对 webpack 熟悉的朋友们应该都知道插件的用法,使用 MF 就是使用 webpack5 内置的一个新的插件,它的构造函数有几个关键参数,见下表: 参数名 用途 举例 name 容器名 app1 filename federation文件名 remoteEntry.js exposes 导出模块的映射 {//导出的模版需为类似 ./xxx 的相对路径,在消费方引用时即可使用 容器名/xxx 进行引入'./App': './src/App',} shared 共享依赖的配置,通过简单的配置,即可实现项目间依赖的共享。 {react: { singleton: true }} 实战 下面我们来看一个实际配置的例子 //app1的配置 new ModuleFederationPlugin({ name: "app1", remotes: { //通过ExternalTemplateRemotesPlugin实现的动态remote地址 //在项目入口处配置 window.app2Url = "http://localhost:3002" //等于 app2@http://localhost:3002/remoteEntry.js app2: "app2@[app2Url]/remoteEntry.js", }, shared: {react: {singleton: true}, "react-dom": {singleton: true}}, }) //app2的配置 new ModuleFederationPlugin({ name: 'app2', filename: 'remoteEntry.js', exposes: { './App': './src/App', }, shared: { react: { singleton: true }, 'react-dom': { singleton: true }}, }) app1 与 app2 的入口文档都是如下形式 import("./bootstrap"); 而两个项目中的 bootstrap.js 文件如下: import App from "./App"; import React from "react"; import ReactDOM from "react-dom"; ReactDOM.render(<App/>, document.getElementById("root")); app1 的 src/app.js 文件引用了 app2 的 App.js import React, {Suspense} from "react"; const RemoteApp = React.lazy(() => import("app2/App")); const App = () => { return ( <div> <div style={{ margin:"10px", padding:"10px", textAlign:"center", backgroundColor:"greenyellow" }}> <h1>App1</h1> </div> <Suspense fallback={"loading..."}> <RemoteApp/> </Suspense> </div>) } export default App; 可以看出 app2 将 app.js 暴露了出来作为模块提供方,app1 做为模块消费方引用了 app2 的组件(其实直接同步引入也可以,异步模块可以“同步引入”也是 MF 的魔法之一),并且双方都将入口文件修改成了异步加载的形式,并且都将 react , react-dom 设置为了共享依赖, app1 实际项目运行效果如下图: 分析 看完了运行效果,我们来简单分析一下项目的依赖结构,如下图: 我们打开 app1 项目页面的 network 看板 可以看到 app1 的项目加载逻辑如下: app1/main.js => app2/remoteEntry.js && app1/bootstrap.js => app2/react && app2/react-dom && app2/app.tsx 可以看到 app1 直接使用了 app2 的 react , react-dom 模块,“完美”的实现了模块的远端共享。 原理浅析 webpack5 之前的 webpack 模块分类 分类 作用 webpack runtime webpack的运行时代码,负责处理模块加载与解析 项目入口文件 用户代码执行的入口 同步chunk 直接import的模块 异步chunk import()引入的模块 模块加载逻辑 加载模块并执行,执行过程中如果遇到静态 import,回到 1 如果遇到动态 import,前往 2 先把异步模块相关的异步模块加载到 modulesMap ,回到 1,递归加载模块 开始执行用户代码 在 webpack5 中,由于 MF 的出现,同步 chunk 与 异步 chunk 又都可以分成两种,如下: 分类 作用 同步非共享模块 import React from 'react'不做任何 MF配置的模块 同步共享模块(webpack5特有) import React from 'react'同时把React设置为 MF的共享模块:new ModuleFederationPlugin({ shared: { react: { 一些配置 } }}) 异步本地模块 import ('./async-component')就是通过动态import引用的本地模块 异步远端模块(webpack5特有) import('remote-app/async-component')通过动态import引用的远端(remote-app)模块 加载逻辑发生了变化 加载执行模块,处理解析模块中遇到的模块引入,执行过程中 如果遇到静态 import如果这个模块是同步非共享模块,回到 1 如果这个模块是同步共享模块,前往 3 如果遇到动态 import如果这个模块是异步本地模块,前往 4 如果这个模块是异步远端模块,也前往 4 先把异步模块相关的异步模块加载到 modulesMap如果是来自 2.b,会初始化 shared scope ,用来处理依赖共享相关逻辑 如果是来自 3.a,没有额外操作 如果是来自 3.b,会利用 shared scope 来复用依赖,实现依赖共享 那可能会有小伙伴疑问上面提到的 shared scope 是个什么东西,我们可以看看 webpack 的官方文档中的动态引入 MF 组件的代码实例: function loadComponent(scope, module) { return async () => { // Initializes the shared scope. Fills it with known provided modules from this build and all remotes await __webpack_init_sharing__('default'); const container = window[scope]; // or get the container somewhere else // Initialize the container, it may provide shared modules await container.init(__webpack_share_scopes__.default); const factory = await window[scope].get(module); const Module = factory(); return Module; }; } loadComponent('abtests', 'test123'); webpack_init_sharing 就是在初始化 shared scope ,我们再到上文例子中的 app1 项目中的 src/app.js 加入console.error(__webpack_init_sharing__);这段代码,然后再运行项目,就可以在开发工具面板里找到下面的这段 webpack runtime 代码: /* webpack/runtime/sharing */ (() => { __webpack_require__.S = {}; var initPromises = {}; var initTokens = {}; __webpack_require__.I = (name, initScope) => { if(!initScope) initScope = []; // handling circular init calls var initToken = initTokens[name]; if(!initToken) initToken = initTokens[name] = {}; if(initScope.indexOf(initToken) >= 0) return; initScope.push(initToken); // only runs once if(initPromises[name]) return initPromises[name]; // creates a new share scope if needed if(!__webpack_require__.o(__webpack_require__.S, name)) __webpack_require__.S[name] = {}; // runs all init snippets from all modules reachable var scope = __webpack_require__.S[name]; var warn = (msg) => (typeof console !== "undefined" && console.warn && console.warn(msg)); var uniqueName = "app1"; var register = (name, version, factory, eager) => { var versions = scope[name] = scope[name] || {}; var activeVersion = versions[version]; if(!activeVersion || (!activeVersion.loaded && (!eager != !activeVersion.eager ? eager : uniqueName > activeVersion.from))) versions[version] = { get: factory, from: uniqueName, eager: !!eager }; }; var initExternal = (id) => { var handleError = (err) => (warn("Initialization of sharing external failed: " + err)); try { var module = __webpack_require__(id); if(!module) return; var initFn = (module) => (module && module.init && module.init(__webpack_require__.S[name], initScope)) if(module.then) return promises.push(module.then(initFn, handleError)); var initResult = initFn(module); if(initResult && initResult.then) return promises.push(initResult['catch'](handleError)); } catch(err) { handleError(err); } } var promises = []; switch(name) { case "default": { register("react-dom", "17.0.2", () => (Promise.all([__webpack_require__.e("vendors-node_modules_react-dom_index_js"), __webpack_require__.e("webpack_sharing_consume_default_react_react")]).then(() => (() => (__webpack_require__(/*! ../node_modules/react-dom/index.js */ "../node_modules/react-dom/index.js")))))); register("react", "17.0.2", () => (Promise.all([__webpack_require__.e("vendors-node_modules_react_index_js"), __webpack_require__.e("node_modules_object-assign_index_js")]).then(() => (() => (__webpack_require__(/*! ../node_modules/react/index.js */ "../node_modules/react/index.js")))))); initExternal("webpack/container/reference/app2"); } break; } if(!promises.length) return initPromises[name] = 1; return initPromises[name] = Promise.all(promises).then(() => (initPromises[name] = 1)); }; })(); 代码有亿点点多,实际就是内部维护了一个 shared scope 名称与该 shared scope 下的模块名、模块版本、远端地址等信息的映射,并根据 模块消费方 与 模块提供方 的配置判断使用哪方的共享模块。 使用场景 个人总结了 MF 的几种使用场景,如下: 首当其冲的当然是不同项目间的模块与组件的共享了,但是在实践过程个人发现一些公共模块可能并不适合使用 MF 来共享,可能更适合使用私有 npm 包的形式共享。但是如果有 A 项目需要使用 B 项目 中的某些业务组件或某项目需要可以热更新的远端模块这样的场景,MF 可以说是有奇效。 微前端,emp 就是一个以 MF 为基础实现的完善的微前端框架。得益于 MF 的远端共享与依赖共享机制,甚至我们可以自行通过对 MF 简单的封装来实现一个简单的微前端框架。 实用技巧 相信大家看完上面的部分,应该对 MF 有了一定的认知,下面介绍几种在实际运用中比较有用的技巧: 远端动态加载 MF 模块 export async function loadRemoteComponent(config) { return loadScript(config).then(() => loadComponentByWebpack(config)) } function loadScript(config) { return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = config.url script.type = 'text/javascript' script.async = true script.onload = () => { console.log(`Dynamic Script Loaded: ${config.url}`) document.head.removeChild(script); resolve(); } script.onerror = () => { console.error(`Dynamic Script Error: ${config.url}`) document.head.removeChild(script) reject() } document.head.appendChild(script) }) } async function loadComponentByWebpack({ scope, module }) { // 初始化共享作用域,这将使用此构建和所有远程提供的已知模块填充它 await __webpack_init_sharing__('default') const container = window[scope] // 获取容器 // 初始化容器,它可以提供共享模块 await container.init(__webpack_share_scopes__.default); const factory = await window[scope].get(module); return factory(); } 但在个人实际使用中,发现了这种方式有共享模块无法同步加载的痛点(或者说依赖实现了 top-level-await 的运行环境),更适合在共享 vue react 等业务组件的场景下使用。我们来看下面的场景: 假如 app2 项目想使用 app1 通过 MF 导出一个水印模块,那正常的使用应该如下: import waterMask from 'app1/waterMask'; waterMask.init(); 如果想远端引入,就要把代码修改成下面这样: import { loadComponent } from 'mf-util'; let waterMask = await loadRemoteComponent({ url: 'http://localhost:3001', scope: 'default', module: 'waterMask' }).default; waterMask.init() 乍一看问题也不大,但首先 top-level-await 就是一个兼容性很差的语法,而且目前 babel 等打包工具还没有完善的转译方案,所以如果项目兼容性要求高,就可以直接否决,其次如果这个远端加载的模块有一定的加载初始化顺序要求,可能要对原来的代码做破坏性的更改。 动态 MF 远端地址 这个其实在上文中的例子就有展示: //app1 wepback.js const ExternalTemplateRemotesPlugin = require('external-remotes-plugin'); plugins: [ new ModuleFederationPlugin({ name: "app1", remotes: { //通过ExternalTemplateRemotesPlugin实现的动态remote地址 //在项目入口处配置 window.app2Url = "http://localhost:3002" //等于 app2@http://localhost:3002/remoteEntry.js app2: "app2@[app2Url]/remoteEntry.js", }, shared: {react: {singleton: true}, "react-dom": {singleton: true}}, }), new ExternalTemplateRemotesPlugin(), ... ], //app1 index.js window.app2Url = "http://localhost:3002" 安装配置 external-remotes-plugin 插件,将 remotes 配置里的 url 地址修改成 app2@[xxx]/remoteEntry.js 的形式,然后在入口文件最上面通过设置 window.xxx 就可以实现了。 远端模块类型问题 假如是用了 typescript 的项目,引入远端模块后模块的类型问题也是个头疼的点,下面这段代码(主要参考于 mf-lite 项目,感谢开源大佬们的贡献)就可以帮助你生成 MF模版的 d.ts 类型声明文件(mf.config是项目中抽取出的MF配置,具体使用可以调用 emitMfExposeDeclaration 自由发挥): import * as os from 'os'; import * as path from 'path'; import fs from 'fs-extra'; import { createRequire } from 'module'; import { execa, ExecaChildProcess } from 'execa'; import { ModuleDeclarationKind, Project, SyntaxKind } from 'ts-morph'; import { cwd } from 'process'; import mfConfig from '../mf.config/index.js'; const require = createRequire(import.meta.url); const { writeFile, remove, ensureDir } = fs; export const runCommand = async ( command: string, args?: string[], path?: string, ): Promise<ExecaChildProcess> => { let p = path; if (!p) { p = cwd(); } if (!args) { // \s 匹配任何空白字符,包括空格、制表符、换页符 // eslint-disable-next-line no-param-reassign [command, ...args] = command.split(/\s+/); } return execa(command, args, { cwd: p, stdio: 'inherit', }); }; export interface FileOptions { // 声明文件路径 path: string; // 声明文件模块名称 moduleName: string; } export const bundleModuleDeclare = (fileOptions: FileOptions[]) => { const project = new Project(); const content: string[] = []; fileOptions.forEach(file => { // 添加源代码 const source = project.addSourceFileAtPath(file.path); // 遍历每一个子节点,如果是 SyntaxKind.DeclareKeyword(即 declare 关键词),进行文本替换 source.forEachDescendant(item => { if (item.getKind() === SyntaxKind.DeclareKeyword) { // 删除即可, 需要判断是不是第一个节点,否则会报异常 item.replaceWithText(item.isFirstNodeOnLine() ? 'export' : ''); } }); // 备份根节点 const baseStatements = source.getStructure().statements; // 移除现存的所有节点 source.getStatements().forEach(res => res.remove()); // 创建一个 module declaration,将上面备份的根节点插入之 source.addModule({ name: `'${file.moduleName}'`, declarationKind: ModuleDeclarationKind.Module, hasDeclareKeyword: true, statements: baseStatements, }); // 格式化代码 source.formatText(); // 补充一些注释 content.push(`// module name: ${file.moduleName}\n\n`); content.push(source.getText()); content.push('\n'); }); return content.join(''); }; export interface BundleFileConfig { // 入口文件路径 entryPath: string; // 输出路径 outputPath: string; } export const bundleTsDeclaration = async (entries: BundleFileConfig[]) => { // 最大并行工作数目为 cpu 核心数 - 1 const maxWorkSize = os.cpus().length - 1; while (entries.length > 0) { const runningItems = entries.splice(0, maxWorkSize); await Promise.all( runningItems.map(item => { const { entryPath, outputPath } = item; return runCommand( path.resolve( require.resolve('dts-bundle-generator'), '../bin/dts-bundle-generator.js', ), [ entryPath, '--out-file', outputPath, '--project', path.resolve(cwd(), 'tsconfig.json'), '--no-banner', ], ); }), ); } }; export const emitMfExposeDeclaration = async ( appConfig: any, baseUrl: string, ) => { // 增加临时缓存文件,用来打包每个小 bundle await ensureDir(path.resolve(baseUrl, '.cache')); const entries: (BundleFileConfig & { name: string })[] = []; for (const expose of Object.keys(appConfig.exposes)) { // 只处理 module 类型 entries.push({ name: expose.replace(/^[./]*/, ''), entryPath: appConfig.exposes[expose], outputPath: path.resolve( baseUrl, '.cache', `${expose.replace(/^[./]*/, '')}.d.ts`, ), }); } // 并行打包 await bundleTsDeclaration(entries.slice()); // 合并上面的所有小 bundle const content = bundleModuleDeclare( entries.map(res => { return { path: res.outputPath, moduleName: `${appConfig.name}/${res.name}`, }; }), ); await writeFile(path.resolve(baseUrl, 'exposes.d.ts'), content); await remove(path.resolve(baseUrl, '.cache')); }; emitMfExposeDeclaration(mfConfig, path.resolve(cwd(), './dist')); Vite 项目中使用 MF 推荐使用 vite-plugin-federation 这款 vite 插件,这款插件可以实现 vite 与 webpack 项目间的 MF 模块复用,但是实现还是有赖于 top-level-await 语法特性,目前还有一些 MF 配置项尚未同步,具体使用各位大佬可以查看该插件文档~ 总结 本文就是 Module Federation 在货拉拉汽销中后台的全部实践经验了,如有不足或异议,欢迎各位大佬评论区指出. 引用: MF没有魔法仅仅是异步chunk 深入探索Webpack5之Module Federation的“奇淫技巧”

123
life.caoalmost 4 years

从bundle产物中分析webpack5 Module Federation

从bundle产物中分析webpack5 Module Federation 背景 我们启动了新的项目,技术选型使用到了webpack5 Module Federation,因此我对该新特性做了一番研究,本文从bundle中来分析webpack是如何实现remote component调用以及package的共享。 带着这两个问题开启咱们探索之旅吧 案例配置 展示配置之前先解释一下host和remote的关系,从微前端角度来解释,host就是基座。remote就是子应用 Remote new ModuleFederationPlugin({ // 子应用名称 name: 'home', // 获取子应用入口 filename: 'remoteEntry.js', exposes: { // 将这两个组件共享出去 './Content': './src/components/Content', './Button': './src/components/Button', }, shared: { // 与基座共享一个package vue: { singleton: true, }, }, }) Host new ModuleFederationPlugin({ // 基座名称 name: 'layout', remotes: { // 连接子应用 home: 'home@http://localhost:3002/remoteEntry.js', }, shared: { // 与子应用共享一个package vue: { singleton: true, }, }, }) Host的./src/main.js import { createApp, defineAsyncComponent } from 'vue'; import Layout from './Layout.vue'; const Content = defineAsyncComponent(() => import('home/Content')); const Button = defineAsyncComponent(() => import('home/Button')); const app = createApp(Layout); app.component('content-element', Content); app.component('button-element', Button); app.mount('#app'); bundle分析 看构建后的产物,需要做一些公共方法和变量的解释,这边会更利于你的阅读 __webpack_modules__, 存储的是module,例如webpack配置的entry __webpack_require__,从__webpack_modules__中加载模块 webpack_require.e,动态加载模块,比如,import('./Button') webpack_require.o,判断对象中是否有该元素,用的是hasOwnProperty该方法 webpack_require.d,在对象上定义getter方法,用的是 defineProperty webpack_require.S,存储的共享package get方法 webpack_require.l 加载共享package 并将 package 注入到远程组件内 remote的共享组件的入口 remoteEntry.js // .... // module入口 var __webpack_modules__ = { 'webpack/container/entry/home': (__unused_webpack_module, exports, __webpack_require__) => { // remote 向外提供的组件 var moduleMap = { './Content': () => { return Promise.all([ __webpack_require__.e('webpack_sharing_consume_default_vue_vue'), __webpack_require__.e('src_components_Content_vue-_b1070'), ]).then(() => () => __webpack_require__('./src/components/Content.vue')); }, './Button': () => { return Promise.all([ __webpack_require__.e('webpack_sharing_consume_default_vue_vue'), __webpack_require__.e('src_components_Button_js-_e56a0'), ]).then(() => () => __webpack_require__('./src/components/Button.js')); }, }; // host 通过该方法获取 ‘./Content’ ‘./Button’ 组件 var get = (module, getScope) => { __webpack_require__.R = getScope; getScope = __webpack_require__.o(moduleMap, module) ? moduleMap[module]() : Promise.resolve().then(() => { throw new Error('Module "' + module + '" does not exist in container.'); }); __webpack_require__.R = undefined; return getScope; }; // host 将共享的package注入到remote,便于 ‘./Content’ ‘./Button’获取 package var init = (shareScope, initScope) => { if (!__webpack_require__.S) return; var name = 'default'; var oldScope = __webpack_require__.S[name]; if (oldScope && oldScope !== shareScope) throw new Error( 'Container initialization failed as it has already been initialized with a different share scope', ); __webpack_require__.S[name] = shareScope; return __webpack_require__.I(name, initScope); }; // 给 webpack/container/entry/home 模块添加 getter,get 和 init 方法 // getter 是 __webpack_require__.d 实现的 __webpack_require__.d(exports, { get: () => get, init: () => init, }); }, }; // ... // 加载 上面的 webpack/container/entry/home module var __webpack_exports__ = __webpack_require__('webpack/container/entry/home'); // 将 webpack/container/entry/home module导出的两个方法 get 和 init 挂载到 home 上 home = __webpack_exports__; 上面是remote的入口文件 remoteEntry.js,它主要干了这些事 window 挂在 home 变量,home是 remote和host沟通的桥梁 加载 webpack/container/entry/home,将 remote的Content、Button 组件get和共享package注入方法暴露出去 先简单介绍上面两个,其实已经将咱们的问题解释了三分之一了(如何实现remote component调用以及package的共享)。 咱们接着看看,Host干了什么 Host 入口 main Host这边做的比较多,咱们从入口文件一段段代码分析 // host 模块入口 var __webpack_modules__ = { // 入口module 入口 './src/index.js': (__unused_webpack_module, __unused_webpack_exports, __webpack_require__) => { __webpack_require__ .e(/* import() */ 'src_main_js') .then(__webpack_require__.bind(__webpack_require__, './src/main.js')); }, // remote module 的入口 其实就是 remoteEntry 'webpack/container/reference/home': (module, __unused_webpack_exports, __webpack_require__) => { var __webpack_error__ = new Error(); module.exports = new Promise((resolve, reject) => { // 防止重复加载 if (typeof home !== 'undefined') return resolve(); // 使用jsonp加载 remoteEntry.js 并执行 remoteEntry.js __webpack_require__.l( 'http://localhost:3002/remoteEntry.js', event => { if (typeof home !== 'undefined') return resolve(); // remote加载或者执行失败回调异常抛出 var errorType = event && (event.type === 'load' ? 'missing' : event.type); var realSrc = event && event.target && event.target.src; __webpack_error__.message = 'Loading script failed.\n(' + errorType + ': ' + realSrc + ')'; __webpack_error__.name = 'ScriptExternalLoadError'; __webpack_error__.type = errorType; __webpack_error__.request = realSrc; reject(__webpack_error__); }, 'home', ); // 返回 remote export === home }).then(() => home); }, }; // .... var __webpack_exports__ = __webpack_require__('./src/index.js'); 上面代码块加载入口模块 ./src/index.js ,加载这个模块主要干了这些事 执行__webpack_require__.e(/* import() */ 'src_main_js')主要去加载共享package vue、remote remoteEntry.js和src_main_js ,并将 vue 的get方式注入到 remote 中,并将 Button和Content 组件的get方法暴露到 host中,具体看代码实现加载共享package vue register("vue", "3.2.41", () => __webpack_require__ // 远程加载vue组件,并将 vue内的模块通过 ./node_modules/@vue/runtime-dom/dist/runtime-dom.esm-bundler.js 挂在到 _webpack_module_ 中 .e("vendors-node_modules_vue_runtime-dom_dist_runtime-dom_esm-bundler_js") .then( () => () => // 从 _webpack_module_ 中获取 vue __webpack_require__( "./node_modules/@vue/runtime-dom/dist/runtime-dom.esm-bundler.js" ) ) ); 加载 remoteEntry.js & 将 vue 的get方式注入到 remote 中 initExternal('webpack/container/reference/home'); var initExternal = id => { // id: webpack/container/reference/home // ... try { // __webpack_require__(id) 直接从 __webpack_modules__ 中获取,可以看看 上 __webpack_modules__['webpack/container/reference/home']加载 var module = __webpack_require__(id); if (!module) return; // var initFn = module => // module: remoteEntry.js 抛出来的全局home // module.init === home.init, home.init 将 共享package的vue注入到remoteEntry中 module && module.init && module.init(__webpack_require__.S[name], initScope); // remoteEntry加载完成之后,执行注入 if (module.then) return promises.push(module.then(initFn, handleError)); var initResult = initFn(module); if (initResult && initResult.then) return promises.push(initResult['catch'](handleError)); } catch (err) { handleError(err); } }; 加载 host的 ./src/main.js // jsonp形式加载 ./src/main.js __webpack_require__.f.j = (chunkId, promises) => { var url = __webpack_require__.p + __webpack_require__.u(chunkId); __webpack_require__.l(url, loadingEnded, 'chunk-' + chunkId, chunkId); }; // 加载完成之后,将 ./src/main.js 放到 __webpack_module_中 // webpackChunk_vue3_demo_layout.push 实现了将 ./src/main.js 放到 __webpack_module_中 (self['webpackChunk_vue3_demo_layout'] = self['webpackChunk_vue3_demo_layout'] || []).push([ ['src_main_js'], { './src/main.js': (__unused_webpack_module, __webpack_exports__, __webpack_require__) => {}, }, ]); Button和Content 组件的get方法暴露到 host中 其实这个方式分析 remote remoteEntry.js 已经提到了,就是home.get 这个方法,如何去获取的,咱们继续往下面分析 执行完__webpack_require__.e(/* import() */ 'src_main_js')之后,继续执行后面的 .then(__webpack_require__.bind(__webpack_require__, './src/main.js')) 这其实就是去加载 ./src/main.js 模块并执行,也就是去执行vue那部分代码了,里面就涉及到了 加载 remote的Button 和Content 加载 remote 的 Button const Button = (0, runtime_dom_esm_bundler_js_.defineAsyncComponent)(() => // 下载远程的组件 Button,这里实际执行的是 __webpack_require__.f.remotes 方法 __webpack_require__ .e(/* import() */ 'webpack_container_remote_home_Button') .then( // 加载 Button 组件 __webpack_require__.t.bind(__webpack_require__, 'webpack/container/remote/home/Button', 23), ), ); const app = (0, runtime_dom_esm_bundler_js_.createApp)(Layout); app.component('content-element', Content); 看看 webpack_require.f.remotes 如何下载 remote Button先通过 webpack/container/reference/home module查找到 remoteEntry.js export,也就是上面说的 home,包含get和init方法 然后通过 home.get('./Button') 获取 对应的remote Button,并将它缓存下下来 /* webpack/runtime/remotes loading */ (() => { var chunkMapping = { webpack_container_remote_home_Button: ['webpack/container/remote/home/Button'], }; var idToExternalAndNameMapping = { 'webpack/container/remote/home/Button': [ 'default', './Button', 'webpack/container/reference/home', ], }; // chunkId: webpack_container_remote_home_Button __webpack_require__.f.remotes = (chunkId, promises) => { if (__webpack_require__.o(chunkMapping, chunkId)) { chunkMapping[chunkId].forEach(id => { // id: webpack/container/remote/home/Button var getScope = __webpack_require__.R; if (!getScope) getScope = []; // data: ['default', './Button', 'webpack/container/reference/home']; var data = idToExternalAndNameMapping[id]; if (getScope.indexOf(data) >= 0) return; getScope.push(data); if (data.p) return promises.push(data.p); var handleFunction = (fn, arg1, arg2, d, next, first) => { try { var promise = fn(arg1, arg2); if (promise && promise.then) { var p = promise.then(result => next(result, d), onError); if (first) promises.push((data.p = p)); else return p; } else { return next(promise, d, first); } } catch (error) {} }; var onExternal = (external, _, first) => external ? handleFunction(__webpack_require__.I, data[0], 0, external, onInitialized, first) : onError(); var onInitialized = (_, external, first) => handleFunction(external.get, data[1], getScope, 0, onFactory, first); var onFactory = factory => { data.p = 1; __webpack_require__.m[id] = module => { module.exports = factory(); }; }; handleFunction(__webpack_require__, data[2], 0, 0, onExternal, 1); }); } }; })(); 总结 用一张图来解释一下,package如何共享、remote component 如何加载的

7
life.caoalmost 4 years

Tailwind CSS的实践和流程分析

Tailwind CSS的实践和流程分析 背景介绍 之前项目中遇到使用Tailwind CSS和CSS Variables相关问题,在解决问题的过程中初步看了下Tailwind CSS的流程,跟大家分享下~ 众所周知,Tailwind CSS可以通过CL、PostCSS、CDN的方式来使用,这篇分享主要是以PostCSS插件的使用方式来讲述的。 不了解Tailwindcss的同学查看官方文档Get started with Tailwind CSS。 在开始这篇文章之前,请大家先思考几个问题: Tailwind CSS是动态编译出CSS样式,还是统一生成CSS样式再TreeShaking掉多余的classNames? TSX文件中的classNames最终是编译成JSX函数内参数的,那这些classNames是如何被Tailwind CSS插件检测到并进行编译转换呢? Tailwind CSS的类名和对应的样式是怎样生成的? 入口配置 首先做好配置。增加tailwind.config.js/postcss.config.js文件,并在主css文件中引入tailwind。 // tailwind.config.js module.exports = { content: ['./src/**/*.{js,jsx,ts,tsx}'], theme: { extend: {}, }, } // main.css @tailwind base; @tailwind components; @tailwind utilities; // postcss.config.js module.exports = { plugins: { tailwindcss: {}, autoprefixer: {}, }, } tailwind.config.js中的content: ['./src//*.{js,jsx,ts,tsx}']的字段表示将会扫描的文件路径,检测是否存在匹配的Tailwind CSS className**值,如果有的话,再做相应处理。 // 声明Tailwind CSS插件 module.exports = function tailwindcss () { postcssPlugin: 'tailwindcss', plugins: [ function (root, result) { let context = setupTrackingContext(configOrPath) // ...省略其他代码 processTailwindFeatures(context)(root, result) } ] } 在创建tailwindcss插件的时候,setupTrackingContext函数主要是创建了上下文实例: context: { classCache, candidates, changedContent }classCache:用来缓存className值; candidates:正则匹配出来的字符串,比如import render classNames等; candidateRuleMap: 保存各个关键字(比如font, text等)的处理函数及相关数据; changedContext: 通过fast-glob函数查找到tailwind.config.js中content对应的文件路径,并读取文件内容。 Tailwind特性处理 接下来是processTailwindFeatures函数,主要在这个函数中做了很多特性处理: expandTailwindAtRules partitionApplyAtRules evaluateTailwindFunctions substituteScreenAtRules .etc 这里我们只看expandTailwindAtRules函数,它主要做了以下3件事: 匹配Tailwind CSS类名 生成CSS样式 调用postcss.process方法生成Rule、Decl等AST节点 匹配Tailwind CSS类名 以App.vue文件为例 <template> <div id="app"> <h1 class="text-3xl font-bold underline"> Hello world! </h1> </div> </template> <script> export default { name: 'App', data() { return { }; }, created() { }, destroyed() { } }; </script> getClassCandidates读取到了‘text-3xl font-bold underline’ 这3个Tailwind CSS的className值。参数content即App.vue文件内容,通过\n换行符,逐行匹配。 extractor通过正则匹配到[ 'text-3xl', 'font-bold', 'underline' ],并保存到candidates中: candidates: new Set(['text-3xl', 'font-bold', 'underline']) function getClassCandidates(content, extractor, candidates, seen) { for (let line of content.split("\n")){ line = line.trim(); let extractorMatches = extractor(line).filter((s)=>s !== "!*"); let lineMatchesSet = new Set(extractorMatches); for (let match1 of lineMatchesSet){ candidates.add(match1); } extractorCache.get(extractor).set(line, lineMatchesSet); } } getExtractor函数 export function defaultExtractor(context) { let patterns = Array.from(buildRegExps(context)) return (content) => { /** @type {(string|string)[]} */ let results = [] for (let pattern of patterns) { results.push(...(content.match(pattern) ?? [])) } return results.filter((v) => v !== undefined).map(clipAtBalancedParens) } } 生成CSS样式 Tailwind升级到3这个大版本后,是动态生成CSS样式的。 它提供了很多【关键字-处理函数】【主题key-值】这样的映射表(详见corePlugins.js) 再通过内置的处理函数fn1 fn2,查找某个主题的值,比如:具体可看corePlugins.js/defaultConfig.stub.js。 // 关键字key-处理函数utilities { text: [ [ { options: { values: { 3xl: ['1.875rem', { lineHeight: '2.25rem' } ] }, }, layer: 'utilities' // base utilities }, function fn1() {} ], [ {}, function fn2() {} ], ] } //主题key-value { fontSize: { base: ['1rem', { lineHeight: '1.5rem' }], '3xl': ['1.875rem', { lineHeight: '2.25rem' }], // ... }, } 从第一步拿到candidates后,再通过generateRules来生成css样式。那到底是怎样生成的呢? function generateRules(candidates, context) { let allRules = []; for (let candidate of candidates){ // ...省略代码 let matches = Array.from(resolveMatches(candidate, context)); // ...省略代码 context.classCache.set(candidate, matches); allRules.push(matches); } // ...省略代码 return allRules.flat(1).map(([{ sort , layer , options }, rule])=>{ // ...省略代码 return [ sort | context.layerOrder[layer], rule ]; }); } 其中,resolveMatches函数承担了这一工作。以text-3xl为例,通过'-'分隔符将其变换成['text', '3xl'],再根据Tailwind提供的处理函数candidateRuleMap.get('text'),生成 .text-3xl { font-size: '1.875rem', line-height: '2.25rem' } 黄色部分是通过dlv函数从defaultConfig.stub.js获取到的默认值: // tailwindConfig: { theme: { screens: {}, fontSize: {] } } dlv(tailwindConfig, ['theme', 'fontSize', ...[]], undefined) 生成AST节点 最后再调用PostCSS方法生成AST节点,这样就可以配合其他PostCSS插件,比如autoprefixer,一起来使用了。 export default function parseObjectStyles(styles) { if (!Array.isArray(styles)) { return parseObjectStyles([styles]) } return styles.flatMap((style) => { return postcss([ postcssNested({ bubble: ['screen'], }), ]).process(style, { parser: postcssJs, }).root.nodes }) } 总结 最后我画了一个简图来辅助梳理思路。 看到这里,你应该能回答文章开头的几个问题了吧? Q: Tailwind CSS是动态编译出CSS样式,还是统一生成CSS样式再TreeShaking掉多余的classNames? A: 最新版本的Tailwind CSS是动态编译出CSS样式的。 Q: TSX文件中的classNames最终是编译成JSX函数内参数的,那这些classNames是如何被Tailwind CSS插件检测到并进行编译转换呢? A: 以main.css文件为契机,执行PostCSS插件提供的函数,再通过fast-glob读取文件内容,进而通过正则来进行匹配。 Q: Tailwind CSS的类名和对应的样式是怎样生成的? A: 通过不同的映射,根据不同的默认值和对应的处理函数,生成不同的样式。具体可看corePlugins.js/defaultConfig.stub.js

90
life.caoalmost 4 years

H5实现pdf和excel在线预览

H5实现pdf和excel在线预览 需求背景 经线下调研发现,在开具电子普票人群中99%以上存在“开票时填财务邮箱接收电子发票/行程单,方便进行下载、对账”的使用场景。 如果能够支持开票人将电子发票分享给微信好友,可带来新用户数和订单数增长。 分享后的电子发票页支持在线预览发票、行程单、费用明细等文件,文件类型包含:图片、PDF、EXCEL 依赖的开源库 Xlsx Pdf-dist html2canvas Excel在线预览实现 实现步骤 1、设置请求响应类型responseType: arraybuffer,请求接口将二进制文件流转为arraybuffer格式 2、XLSX.readFile方法解析数据,获取Worksheet工作表 3、XLSX.utils.sheet_to_html回调方法获取生成的html,渲染html到容器 4、将渲染之后的html,通过html2canvas绘制成canvas,canvas.toDataURL获取到base64图片路径 5、加载base64图片 功能截图 代码演示 /** * 读取文件流并输出html */ readExcelFile(url) { axios.get(url, { responseType: 'arraybuffer' }) .then((res)=>{ // 解析数据 let readFile = XLSX.readFile(res.data) // 获取工作表 let { Worksheet } = readFile.Sheets var container = document.getElementById("tavolo"); // 获取对应table html,渲染至容器 container.innerHTML = XLSX.utils.sheet_to_html(Worksheet); // 绘制canvas,生成图片 this.drawCanvas() }).catch( err =>{ console.log(err) }) }, /** * 绘制canvas图片 */ drawCanvas() { let canvasDom = document.getElementById('tavolo') (window.html2canvas || html2canvas)(canvasDom, { useCORS: true, scale: isIOS() ? 1 : 2, // 区别ios、安卓,设置缩小比例 scrollX: 0, scrollY: 0, allowTaint: true }).then(function(canvas) { const imgData = canvas.toDataURL("image/jpeg", 1.0); // base64 url ImagePreview({ // 预览图片 images: [ imgData ], closeable: true, maxZoom: 10, closeIconPosition: 'left', closeIcon: require('@/assets/images/ico_preview_close.png'), transition: 'van-slide', }) showLoading({ show: false }) }); } Excel在线预览补充说明 1、由于生成canvas大小限制问题,iOS需单独控制缩小比例,不同的浏览器内核对 canvas 的处理策略不一样,它可能超出了 Safari 浏览器的限制,而 chrome 是没有这个限制,所以安卓手机是正常的。 2、chrome、Firefox、Safari 等浏览器对 canvas 的总内存占用限制、单个 canvas 的限制(如 width、height、像素密度)不尽相同。在大量使用 canvas 时没有注意及时回收,导致了他在 chrome 测试没问题的代码,Firefox 中完全没有反应,在 Safari 中报错。 3、引入方式统一为:import * as XLSX from 'xlsx/xlsx.mjs',避免xlsx高低版本差异 4、html2canvas需要加上scrollX: 0以及scrollY: 0,避免绘制的起点不同,出现空白间距 5、canvas所属dom元素尽量加大宽高值,否则会出现布局错乱,再利用translate3d偏移就可以使canvas不会出现在可视区域 6、html2canvas版本固定为v1.0.0-rc.4,减少兼容问题 Pdf在线预览实现 实现步骤 1、组件挂载时,初始化pdfjs-dist sdk 2、请求获取pdf文件,返回数据格式建议为:二进制文件流(需通过Buffer转成base64)或者base64串(首选) 3、pdfjs-dist根据base64获取pdf信息,如pdf页数numPages,从而绘制分页canvas 4、由canvas获取base64路径,加载图片 功能截图 代码演示 /** * 生成canvas * @params {pdf} pdf pdf对象 * @params {Number} page pdf当前页数 */ async drawCanvas(pdf, page) { let canvas = document.createElement('canvas'); let page = await pdf.getPage(page) //调用getPage方法传入当前循环的页数,返回一个page对象 let ctx = canvas.getContext('2d') let dpr = window.devicePixelRatio || 1 let bsr = ctx.webkitBackingStorePixelRatio || ctx.mozBackingStorePixelRatio || ctx.msBackingStorePixelRatio || ctx.oBackingStorePixelRatio || ctx.backingStorePixelRatio || 1 let ratio = dpr / bsr let viewport = page.getViewport(screen.availWidth / page.getViewport(1).width) canvas.width = viewport.width * ratio canvas.height = viewport.height * ratio canvas.style.width = viewport.width + 'px' canvas.style.height = viewport.height + 'px' ctx.setTransform(ratio, 0, 0, ratio, 0, 0) let renderContext = { canvasContext: ctx, viewport: viewport } await page.render(renderContext) this.base64List.push(canvas.toDataURL('image/png')) }, /** * 绘制canvas,预览pdf * @params {String} stream 文件base64 */ async showPdf (stream) { try { let decodedBase64 = window.atob(stream) //使用浏览器自带的方法解码 let pdf = await PDFJS.getDocument({ data: decodedBase64, cMapUrl: "https://unpkg.com/pdfjs-dist@2.2.228/cmaps/", cMapPacked: true }) //返回一个pdf对象 let { numPages } = pdf._pdfInfo for (let i = 1; i <= numPages; i++) { this.drawCanvas(pdf, i) } this.previewImages() } catch (err) { console.log(err) } } Pdf在线预览补充说明 1、由于一些网络通讯协议的限制,使用 window.btoa() 方法对base64数据进行编码后返回给客户端。所以需window.atob解码之后,才能使用 2、pdfJs执行参数中cMapUrl,为pdf生成时所需的字体文件cdn链接,尽量勿使用国外cdn,如jsdelivr.net,避免服务器不稳定等因素导致文件加载失败,pdf绘制失败,最好是下载对应文件放置公司cdn服务再引入 3、base64 url获取需等page.render执行之后才可以获取 4、pdfjs-dist采用版本: 2.0.943,避免编译错误问题

164
life.caoabout 4 years

小程序优化首包大小—分包异步化

小程序优化首包大小—分包异步化 理论 微信官方建议性能优化中关于代码体积优化有以下 4 点: 合理使用分包加载 非主包资源都可以放到分包中,保证主包资源最快时间让用户可以访问,分包的资源可以按照重要性开启分包预下载。 避免非必要的全局自定义组件和插件 如题,非必要(组件功能单一、只有 1 个页面引入等)就不要把组件写在主包,拆进分包。多个分包都同时引用的资源,放在分包里则不合适,分包没法直接访问另一个分包的资源,除非两个分包都已经加载过了。这个问题可以使用「分包异步化」解决,这是本文重点,下文再表。 控制代码包内的资源文件 图片、字体文件建议尽量都走 CDN,小程序的 WXSS 中图片资源没法访问本地路径,也是建议把图片资源放在 CDN 上。 及时清理无用代码和资源 实践 控制小程序代码包大小主要几个手段: 静态资源,能走 CDN 的,全部走 CDN。 能分包的页面或者组件,全部放到分包里面去,主包只留不能分的。 如果资源一定要在主包引用且大小不可控,那就使用「分包异步化」或者「分包插件异步化」来处理。「分包异步化」和「分包插件异步化」两者的选择建议:如果用的是第三方编译的小程序框架,比如 Uniapp,那就用不上「分包异步化」,等它支持 分包插件异步化和分包异步化写法差不多,坏处是需要发一个微信小程序插件,好处是小程序是跨端编译到其它端也可以走插件这一套逻辑。 最终首包由1.8M减到1.2M左右

340
life.caoabout 4 years

徒手开Webpack 5系

徒手开Webpack 5系 背景 最近项目都开始切Webpack5,想徒手开一个 前提 随手打开过 webpack 的官网 随手打开过 babel 的官网 简单了解过 tapable 的功能 简单了解过 node 是干嘛的 简单写过点 js 执行过 npm run build (这一步很关键,所以我直接加粗 + 标红) 版本 5.73.0 示例 详情 1.目录结构 . ├── main │ └── index.js ├── package-lock.json ├── package.json ├── src │ ├── div.js │ ├── mul.js │ ├── sub.js │ └── sum.js ├── webpack.config.js 2.文件内容 sum.js function sum(num1, num2) { return num1 + num2 } module.exports = { sum } sub.js function sub(num1, num2) { return num1 - num2 } module.exports = { sub } mul.js function mul(num1, num2) { return num1 * num2 } module.exports = { mul } div.js function div(num1, num2) { return num1 / num2 } module.exports = { div } index.js const { sum } = require("../src/sum.js"); const { sub } = require("../src/sub.js"); const { mul } = require("../src/mul.js"); const { div } = require("../src/div.js"); const sumResult = sum(50, 50); const subResult = sub(50, 50); const mulResult = mul(50, 50); const divResult = div(50, 50); console.log({ sumResult, subResult, mulResult, divResult, }); webpack.config.js const webpack = require("webpack"); const path = require("path"); module.exports = { entry: "./main/index.js", output: { path: path.resolve(__dirname, "dist"), filename: "[name].bundle.js", }, mode: "development", devtool: "source-map", module: { rules: [ { test: /\.m?js$/, exclude: /node_modules/, use: { loader: "babel-loader", options: { presets: ["@babel/preset-env"], }, }, }, ], }, plugins: [ new webpack.BannerPlugin({ banner: "webpack! webpack! webpack! webpack! webpack!", footer: true, }), ], }; 3.打包产物 (() => { var __webpack_modules__ = { "./src/div.js": (module) => { function div(num1, num2) { return num1 / num2; } module.exports = { div: div, }; }, "./src/mul.js": (module) => { function mul(num1, num2) { return num1 * num2; } module.exports = { mul: mul, }; }, "./src/sub.js": (module) => { function sub(num1, num2) { return num1 - num2; } module.exports = { sub: sub, }; }, "./src/sum.js": (module) => { function sum(num1, num2) { return num1 + num2; } module.exports = { sum: sum, }; }, }; var __webpack_module_cache__ = {}; function __webpack_require__(moduleId) { var cachedModule = __webpack_module_cache__[moduleId]; if (cachedModule !== undefined) { return cachedModule.exports; } var module = (__webpack_module_cache__[moduleId] = { exports: {}, }); __webpack_modules__[moduleId](module, module.exports, __webpack_require__); return module.exports; } (() => { var _require = __webpack_require__("./src/sum.js"), sum = _require.sum; var _require2 = __webpack_require__("./src/sub.js"), sub = _require2.sub; var _require3 = __webpack_require__("./src/mul.js"), mul = _require3.mul; var _require4 = __webpack_require__("./src/div.js"), div = _require4.div; var sumResult = sum(50, 50); var subResult = sub(50, 50); var mulResult = mul(50, 50); var divResult = div(50, 50); console.log({ sumResult: sumResult, subResult: subResult, mulResult: mulResult, divResult: divResult, }); })(); })(); /*! webpack! webpack! webpack! webpack! webpack! */ 4.结果分析 打包之后的代码是一个 IIFE ,主要包含以下内容__webpack_modules__:key 为模块路径,value 为模块内容作为执行体的函数 __webpack_module_cache__:缓存 __webpack_require__:加载模块的方法 IIFE:这里执行 __webpack_require__ 方法并返回 module.exports babel-loader 已将代码处理为 es5 的语法 BannerPlugin 已将内容写入打包后的文件 源码浅析 npm run build 执行 ./node_modules/.bin/webpack 文件 webpack-cli 负责处理执行 command 及 config 相关的配置并传入 webpack webpack 根据传入的 config 以一个或多个 js 文件为入口,递归检查每个js 模块的依赖,从而构建一个依赖关系图,然后依据该图将整个应用程序打包成一个或多个 bundle run 初始化 command 配置并注册相关的 callback 解析 command 执行 callback 加载 webpack 执行 webpack 生成 compiler runWebpack 核心代码如下: async runWebpack(options, isWatchCommand){ const callback = (error, stats) => { ... } compiler = await this.createCompiler(options, callback); } createCompiler 核心代码如下: async createCompiler(options, callback) { let config = await this.loadConfig(options); config = await this.buildConfig(config, options) compiler = this.webpack(config.options, callback) } webpack-cli 相对于 webpack 主要做了以下几件事情: 解析命令行的指令 解析 webpack.config.js 执行 this.webpack(config, cb) 得到 compiler 对象 webpack 该方法接收 options 和 callback 两个参数 根据是否传入了 callback 决定是否执行 compiler.run() create 方法用于创建一个包含 compiler 对象的 object,核心代码如下: const create = () => { let compiler; const webpackOptions = options; compiler = createCompiler(webpackOptions) return { compiler, ...} } webpack 核心代码如下: const webpack = (options, callback) => { const create = () => { let compiler; const webpackOptions = options; compiler = createCompiler(webpackOptions) return { compiler, ...} } if (callback) { const { compiler, ... } = create(); compiler.run() } } createCompiler 对 options 进行 normalized 处理 实例化 compiler 注入 compiler 到 plugin 的 apply 方法中 执行相关的 hooks 在 process() 中将所有的 option 转为相应的 plugin 进行处理 返回 compiler createCompiler 核心代码如下: const createCompiler = rawOptions => { const options = getNormalizedWebpackOptions(rawOptions); applyWebpackOptionsBaseDefaults(options); const compiler = new Compiler(options.context, options); if (Array.isArray(options.plugins)) { for (const plugin of options.plugins) { if (typeof plugin === "function") { plugin.call(compiler, compiler); } else { plugin.apply(compiler); } } } applyWebpackOptionsDefaults(options); compiler.hooks.environment.call(); compiler.hooks.afterEnvironment.call(); new WebpackOptionsApply().process(options, compiler); compiler.hooks.initialize.call(); return compiler; } process 核心代码如下: class WebpackOptionsApply extends OptionsApply { process(options, compiler) { // 1. 处理 options 转为 plugins // 2. 处理 entry 并在 EntryOptionPlugin 中注册 entryOption hook new EntryOptionPlugin().apply(compiler); // 3. 执行上一步注册的 entryOption hook compiler.hooks.entryOption.call(options.context, options.entry); return options; } } EntryOptionPlugin 核心代码如下: class EntryOptionPlugin { apply(compiler) { compiler.hooks.entryOption.tap("EntryOptionPlugin", (context, entry) => { EntryOptionPlugin.applyEntryOption(compiler, context, entry); return true; }); } static applyEntryOption(compiler, context, entry) { new EntryPlugin(context, entry, options).apply(compiler); } } EntryPlugin 核心代码如下: class EntryPlugin { apply(compiler) { // 1. 注册关键的 make hook callback 以便后面调用 compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => { compilation.addEntry(context, dep, options, err => { callback(err); }); }); } } Compiler 简单来讲,compiler 是一个包含当前运行 webpack 所有配置的对象,被实例化时会利用 tapable 初始化大量 hook 以便在 webpack 整个生命周期去注册,因此,一旦被创建便存在于 webpack 的整个生命周期中。 Compiler 核心代码如下: class Compiler { constructor(context, options) { this.hooks = Object.freeze({ // 初始化大量的 hook }) } compile(callback) { const params = this.newCompilationParams(); // 2. 创建 compilation 对象 const compilation = this.newCompilation(params); // 3. 执行 make hook callback this.hooks.make.callAsync(compilation, err => { this.hooks.finishMake.callAsync(compilation, err => { compilation.seal() }) }) } // compiler 被创建后执行 run 方法 run(callback) { const onCompiled = (err, compilation) => {} const run = () => { // 1. 执行 compile this.compile(onCompiled); }; run(); } } Compilation compilation 代表了一次单一的版本构建和资源的生成,即在编译过程中每当检测到某个文件发生变化,就会执行一次新的编译过程,从而生成编译结果。compilation 表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息,也就是说它只存在于编译阶段。 class Compilation { constructor() { this.hooks = Object.freeze({ ... }) this.entries = new Map(); // 存放所有的入口信息 this.modules = new Set(); // 存放解析后所有的模块信息 this.chunks = new Set(); // 存放 chunks 信息 this.assets = {}; // 存放生成的资源信息 } seal() {} // 对资源进行封存最终输出 assets } make 执行 make 的 callback,从 entry 开始对 module 进行 add 、build、parse factorize 会执行 resolver 去处理 module 和loader 的路径相关信息 addModule 用于生成模块间的 ModuleGraph buildModule 会通过 runLoader 执行对应的 loaders 执行 parse 方法解析 module 生成 AST 执行 processModuleDependencies 递归处理模块间的依赖并重复执行上述流程 class EntryPlugin { apply(compiler) { compiler.hooks.make.tapAsync("EntryPlugin", (compilation, callback) => { compilation.addEntry(context, dep, options, err => { callback(err); }); }) } } 执行 make 的回调开始编译,由于编译涉及大量的引用和回调,此处仅沿着执行线进行简单梳理,流程如下: 以上就是处理单个模块的核心流程,拿到本次处理结果后会执行 processModuleDependencies 根据依赖对模块进行递归处理直到 parse 完所有的模块保存在 compilation 中 _doBuild(options, compilation, resolver, fs, hooks, callback) { const processResult = (err, result) => { ... const source = result[0]; const sourceMap = result.length >= 1 ? result[1] : null; ... this._source = this.createSource({...}) return callback(); } runLoaders({ ... }, (err, result) => { ... processResult(err, result.result) }) } callback(err) { const handleParseResult = result => { return handleBuildDone(); }; const source = this._source.source(); result = this.parser.parse(this._ast || source, { source, current: this, module: this, compilation: compilation, options: options }); handleParseResult(result) } seal 执行 compilation.seal() 处理 Optimization 相关配置 根据处理好的 modules 生成 chunks 生成 assets 挂载到 compilation.assets 上 根据 output 配置生成文件夹 执行 emitFiles 输出资源到文件夹 源码小结 总结 由于本文不是专门分析源码,而且webpack 真正的执行过程远比此处复杂得多,所以仅仅只是提取了执行过程中的核心方所做的事情进行概述讲解,使你更轻易的了解到它所做的事情;如果你有时间且有兴趣,不妨可以自己从头撸上一遍,毕竟阅读源码的过程是无聊且枯燥的,能不看就尽量别看了吧,除非你刻意要做那个很卷的人! 至此,我们大概可以明确了手写 webpack 的基本思路,执行过程如下所示: 手写思路 package.json "scripts": { "my-webpack": "node ./webpack/index.js", } 执行该指令模拟去加载那个可执行文件 index require("./webpack-cli") webpack-cli // 1、获取文件的配置 const webpackConfig = require("../webpack.config.js"); // 2、获取命令行配置 const getCommandOption = () => { const commandOptionList = process.argv.slice(2); const option = {}; commandOptionList.forEach((item) => { const [key, value] = item.split("="); if (key[0] !== "-" || key[1] !== "-") { console.error("options is invaild"); return false; } if (key.slice(2) && value) { option[key.slice(2)] = value; } }); return option; }; // webpack-cli 包含大量处理 command 相关的逻辑,这里我就不写了,有兴趣可自行阅读源码 // 3、合并配置 const config = { ...webpackConfig, ...commandConfig }; // 4、执行 webpack webpack(config, (err, stats) => { if (err) { console.err(error); } console.log("stats", stats); }); // 至此,webpack-cli 已经完成了自己的任务 // 关于为什么安装 webpack 也会提示一并安装 webpack-cli ? 我们可以只安装 webpack 吗 ? // 当然可以。因为 webpack 依赖 webpack-cli 会处理配置相关的功能。 // 如果我们可以自己解析并生成 config 交给 webpack 处理,那就只安装 webpack 就可以了 // 毕竟 vite 之前的 vue 和 react ,二者也都没有安装过 webpack-cli webpack const Compiler = require("./Compiler") // 简单处理下 entry 兼容 string 和 object 的形式传入, 数组自己可以尝试处理下哈 const getNormalizedEntryStatic = (entry) => { if (typeof entry === "string") { return { main: entry, }; } if (Object.prototype.toString.call(entry) === "[object Object]") { return { ...entry }; } }; const createCompiler = (options) => { options.entry = getNormalizedEntryStatic(options.entry); const compiler = new Compiler(options); // 将 compiler 传入 plugins 中 if (Array.isArray(options.plugins)) { for (const plugin of options.plugins) { if (typeof plugin === "function") { plugin.call(compiler, compiler); } else { plugin.apply(compiler); } } } return compiler; }; const webpack = (config, callback) => { // 1、创建 compiler 对象 const compiler = createCompiler(config); if (callback) { // 2、调用 run 方法之前我们初始化好的 run hook compiler.hooks.run.call(); // 3、执行 compiler 的 run 方法 compiler.run((err, stats) => { if (err) { console.error(err); } console.log(stats); }); } }; module.exports = webpack Compiler const { SyncHook } = require("tapable"); const Compilation = require("./Compilation") class Compiler { constructor(options) { // 1、简单初始化两个 hook 意思一下 this.hooks = { run: new SyncHook(), done: new SyncHook(), }; // 2、保存一个 context,这里跳过用户配置简单处理 this.context = process.cwd(); // 3、保存一份 options this.options = options; } run() { // 4、创建 Compilation 这里我们把这个 compiler 直接传过去 const compilation = new Compilation(this); // 5、从 entry 开始处理 compilation.addEntry(); } } module.exports = Compiler Compilation const fs = require("fs"); const path = require("path"); const parse = require("./parse") const runLoaders = require("./runLoaders") const codeGeneration = require("./codeGeneration") class Compilation { constructor(compiler) { this.compiler = compiler; this.entries = new Set(); // 入口模块 this.modules = new Set(); // 依赖模块 this.chunks = new Set(); // chunks this.sourceCode = ""; // 源码 this.assets = {}; // 即将打包的资源 } // 读取模块 => 用 loader 处理模块 => 解析模块 build(moduleName, modulePath) { const sourceCode = fs.readFileSync(modulePath, "utf-8"); runLoaders(this, modulePath, sourceCode); return parse(this, moduleName, modulePath); } addEntry() { // 1、从入口开始依次处理 const { context, options } = this.compiler; for (const entryName in options.entry) { if (Object.hasOwnProperty.call(options.entry, entryName)) { const entryPath = options.entry[entryName]; // 2、build 处理 const buildCompletedModule = this.build( entryName, path.resolve(context, entryPath) ); // 3、处理完成添加到 entries this.entries.add(buildCompletedModule); // 4、对资源进行打包输出 this.seal(entryName, buildCompletedModule); } } } addChunk(entryName, entryModule, callback) { const chunk = { name: entryName, entryModule: entryModule, modules: Array.from(this.modules).filter((i) => i.name.includes(entryName) ), }; this.chunks.add(chunk); callback(); } seal(entryName, entryModule) { this.addChunk(entryName, entryModule, () => { this.emitAsset(); }); } emitAsset() { const output = this.compiler.options.output; this.chunks.forEach((chunk) => { const outputFileName = output.filename.replace("[name]", chunk.name); this.assets[outputFileName] = codeGeneration(chunk); }); if (!fs.existsSync(output.path)) { fs.mkdirSync(output.path); } Object.keys(this.assets).forEach((fileName) => { const filePath = path.join(output.path, fileName); fs.writeFileSync(filePath, this.assets[fileName]); }); this.compiler.hooks.done.call(); } } module.exports = Compilation runLoaders // 遍历加载所有的 loader 作用在源码模块上 const runLoaders = (compilation, modulePath, sourceCode) => { const usedLoaders = []; const rules = compilation.compiler.options.module.rules; for (const rule of rules) { if (rule.test.test(modulePath)) { if (rule.loader) { usedLoaders.push(rule.loader); } if (rule.use) { usedLoaders.push(...rule.use); } } } // 倒序处理 for (let i = usedLoaders.length - 1; i >= 0; i--) { compilation.sourceCode = require(usedLoaders[i])(sourceCode); } }; module.exports = runLoaders parse const path = require("path"); const parser = require("@babel/parser"); const { genAbsPath, genModuleId } = require("./utils"); const traverse = require("@babel/traverse").default; const generator = require("@babel/generator").default; const types = require("@babel/types"); const parse = (compilation, moduleName, modulePath) => { // 1. 创建模块 const module = { id: genModuleId(compilation.compiler.context, modulePath), name: [moduleName], __source: "", dependencies: new Set(), }; // 2. 生成 ast const ast = parser.parse(compilation.sourceCode, { sourceType: "module", }); // 3. 对源码进行转换 这里仅针对 require 引入 traverse(ast, { CallExpression: ({ node }) => { if (node.callee.name === "require") { const nodeName = node.arguments[0].value; const nodeDirName = path.posix.dirname(modulePath); const nodeAbsPath = genAbsPath( path.posix.join(nodeDirName, nodeName), compilation.compiler.options.resolve.extensions, nodeName, nodeDirName ); const moduleId = genModuleId(compilation.compiler.context, nodeAbsPath); node.callee = types.identifier("__webpack_require__"); node.arguments = [types.stringLiteral(moduleId)]; // 判断模块是否重复引入处理 const loadedModules = Array.from(compilation.modules).map((i) => i.id); if (!loadedModules.includes(moduleId)) { module.dependencies.add(moduleId); } else { compilation.modules.forEach((i) => { if (i.id === moduleId) { i.name.push(nodeName); } }); } } }, }); // 4. 代码生成 const { code } = generator(ast); // 5. 在 module 对象上挂载一份生成好的源码 module.__source = code; // 6. 处理模块依赖 module.dependencies.forEach((dep) => { const depModule = compilation.build(moduleName, dep); compilation.modules.add(depModule); }); return module; }; module.exports = parse; utils const fs = require("fs"); const path = require("path"); const genAbsPath = (modulePath, extensions, nodeName, nodeDirName) => { if (fs.existsSync(modulePath)) return modulePath; for (const extension of extensions) { if (fs.existsSync(modulePath + extension)) { return modulePath + extension; } } }; const genModuleId = (context, modulePath) => { return `./${path.posix.relative(context, modulePath)}`; }; module.exports = { genAbsPath, genModuleId, }; codeGeneration // 简单模拟下 const codeGeneration = (chunk) => { const { entryModule, modules } = chunk; return ` (() => { var __webpack_modules__ = { ${modules .map((module) => { return ` '${module.id}': (module) => { ${module.__source} } `; }) .join(",")} }; var __webpack_module_cache__ = {}; function __webpack_require__(moduleId) { var cachedModule = __webpack_module_cache__[moduleId]; if (cachedModule !== undefined) { return cachedModule.exports; } var module = (__webpack_module_cache__[moduleId] = { exports: {}, }); __webpack_modules__[moduleId](module, module.exports, __webpack_require__); return module.exports; } (() => { ${entryModule.__source} })(); })(); `; }; module.exports = codeGeneration;

14
life.caoabout 4 years

浅谈浏览器的剪切板 API 及其实践

浅谈浏览器的剪切板 API 及其实践 前言 同事在使用公司后台系统时,常常会把某块内容截图分享到工作群,用于反馈或咨询问题。作为一名熟练掌握 Ctrl C+V 的前端开发,我思考能否实现一个功能,可以一键点击将某块的页面内容准确又快捷地复制到粘贴板,再粘贴到其它地方。 演示: 浏览器中的剪切板:Clipboard API Clipboard 接口实现了 Clipboard API,如果用户授予了相应的权限,其就能提供系统剪贴板的读写访问能力。在 Web 应用程序中,Clipboard API 可用于实现剪切、复制和粘贴功能。Clipboard 是浏览器新提供的剪贴板操作方法,用于取代安全隐患比较大的 document.execCommand。 Clipboard API 目前属于比较新的 API,浏览器仍在逐步实现它,兼容性如下: 需要注意: navigator.clipboard 需要在安全域 https 下使用 读写操作需要用户授权,权限已添加到 Permissions API 中 页面处于活动选项卡才能调用 Clipboard 的所有操作都是异步的,返回 Promise 对象,支持将任意内容放入剪贴板,这就给将图片放入剪贴板提供能实现的可能,可通过全局属性调用 Clipboard API: navigator.clipboard API Clipboard 提供了两个写入剪贴板的方法,分别是: writeText() navigator.clipboard.writeText() 方法可以写入字符串到操作系统的剪切板。 参数 要写入的 DOMString。 返回值 返回一个 Promise 对象,数据写入成功则执行 resolve,否则执行 reject。 <button onclick="copyCurrentTime()">拷贝当前时间</button> <script> async function copyCurrentTime() { await navigator.clipboard.writeText(new Date()); console.log("页面地址已经被拷贝到剪贴板中"); } </script> write() navigator.clipboard.write() 方法支持写入任意数据(文本数据、二进制数据)到剪贴板。 参数 一个包含构造函数 ClipboardItem() 生成的 ClipboardItem 实例 对象数组,这个构造函数接受一个对象作为参数,该对象的 key 是数据的 MIME 类型,value 是 Blob 类型对象数据。 返回值 返回一个 Promise 对象,数据写入成功则执行 resolve,否则执行 reject。 下面例子演示复制图片到剪贴板,根据图片链接获取到资源,转成 Blob 二进制对象,然后使用该对象来构造 ClipboardItem 对象,最后调用 write() 异步写入剪贴板。 代码: async function writeClipImg() { const imgURL = 'https://webapi.XXX.com/assets/default.png'; const data = await fetch(imgURL); const blob = await data.blob(); await navigator.clipboard.write([ new ClipboardItem({ [blob.type]: blob, }), ]); } 上面代码展示的是将图片资源写入剪贴板的过程,那么该如何将为文档流中的 DOM 元素写入剪贴板呢?同样的思路,能否把 DOM 转换成图片? 生成 DOM 图片 社区里比较流行的 npm 库是 html2canvas 和 dom-to-image,他们都是将页面上显示的 DOM 元素,经过解析画在 canvas 上,最后在转换为 Image 或 SVG 格式,以下操作思路主要参考 dom-to-image。 生成 DOM 图片的核心思想是利用了 SVG 的 **[<foreignObject>](https://developer.mozilla.org/zh-CN/docs/Web/SVG/Element/foreignObject)** 元素,该元素可以在 SVG 内部渲染 HTML 标签,那么拥有了包含目标 DOM 的 SVG 后,我们就能很轻易的生产图片。 大致流程如下: 节点 克隆节点使用 Node.``cloneNode``(),但由于需要对每个节点进行「特殊处理」(样式和资源等),使用 cloneNode(deep = true)无法满足需求,所以需要手动遍历克隆。 克隆节点 如果 node 为HTMLCanvasElement,则转成 DataURL 并返回 Image 对象。 /** * @param {*} original 原始节点 * @returns 克隆节点 */ function copyNode(original) { if (original instanceof HTMLCanvasElement) { return new Promise((resolve, reject) => { const image = new Image(); image.src = original.toDataURL(); image.onload = resolve(image); image.onerror = reject; }); } return original.cloneNode(false); } 克隆子节点 还要克隆所有子节点 childNodes。 /** * @param {*} original 原始节点 * @param {*} clone copyNode()返回的克隆节点 * @returns 已克隆子节点的克隆节点 */ function copyChildren(original, clone) { // 获取子节点 const children = original.childNodes; if (children.length === 0) return Promise.resolve(clone); return cloneChildrenInOrder(clone, Array.from(children)) .then(clone); /** * @param {*} parent 父节点 * @param {*} children 克隆的子节点 */ function cloneChildrenInOrder(parent, children) { const done = Promise.resolve(); // 遍历子元素,逐一克隆并复制样式添加到父节点当中 children.forEach(function (child) { done = done .then(() => cloneNode(child)) .then((childClone) => { if (childClone) parent.appendChild(childClone); }); }); return done; } } 结合以上克隆一个节点 /** * @param {*} node 目标节点 * @returns 手动克隆后的节点 */ function cloneNode(node) { return Promise.resolve(node) .then(copyNode) .then((clone) => copyChildren(node, clone)) } 样式 上面提到使用 Node.cloneNode() 会有样式丢失的情况,因此需要对样式重新做赋值处理。 克隆样式 转换的目标是包含指定 DOM 的 SVG,那么所有导入的外部样式将不生效,所以克隆的 className 也无法生效,就需要利用 getComputedStyle 获取原节点的样式属性和值,再遍历样式属性通过setProperty 重新赋值到克隆节点,其中CSS属性优先级则通过 getPropertyPriority 获取,将样式转成内联样式来解决此问题。 /** * @param {*} original 原始节点 * @param {*} clone 克隆节点 */ function cloneStyle(original, clone) { const source = window.getComputedStyle(original); Array.from(source).forEach((name) => { clone.style.setProperty( name, source.getPropertyValue(name), source.getPropertyPriority(name), ); }); } 处理伪元素 对于伪元素 :before、:after 等,cloneNode() 无法克隆,所以需要提取出样式,作为style 添加到节点当中。[window.getComputedStyle()](https://developer.mozilla.org/zh-CN/docs/Web/API/Window/getComputedStyle) API第二个参数支持指定一个伪元素字符串,可以查询到该伪元素的样式信息。 function clonePseudoElements() { [":before", ":after"].forEach((pseudo) => { // 查询指定伪元素 const style = window.getComputedStyle(original, pseudo); const content = style.getPropertyValue("content"); if (content === "" || content === "none") return; // 生成伪元素类名 const className = `xxx`; // 添加伪元素类 clone.className = `${clone.className} ${className}`; // 拼接样式 const cssText = `${Array.from(style) .map((name) => { return `${name}: ${style.getPropertyValue(name)}`; }).join("; ")};` const styleElement = document.createElement("style"); // 样式内容嵌入style styleElement.innerHTML = `.${className}:${pseudo}{ ${cssText} }`; clone.appendChild(styleElement); }); } 图片链接 Img 标签链接 对于 标签,需要将图片链接src转成dataURL形式。 <img src="https://static.xxx.cn/image/6ce5...262f8.svg"> 需要转成: <img src="data:image/svg+xml;base64,PD94b...XXXXX"> 通过图片链接获取资源并处理成 DataURL 返回: function getImgDataURL(url) { return new Promise((resolve) => { // 发起XMLHttpRequest const request = new XMLHttpRequest(); request.onreadystatechange = () => { if (request.readyState !== 4) return; if (request.status !== 200) resolve('') const encoder = new FileReader(); // 将blob类型转成DataURL encoder.readAsDataURL(request.response); encoder.onloadend = function () { resolve(encoder.result); }; }; // blob类型请求 request.responseType = 'blob'; request.open('GET', url, true); request.send(); }); } 替换 Img 的 src: function inlineImage(Image) { // 如果src是DataURL形式则不处理 if (Image.src.search(/^(data:)/) !== -1) return Promise.resolve(); return Promise.resolve(Image.src) .then(getImgDataURL) .then((dataUrl) => { return new Promise((resolve, reject) => { Image.src = dataUrl; Image.onload = resolve; Image.onerror = reject; }); }); } background 样式链接 同样,对于样式使用了 background: url() 来设置背景图片的节点,也要处理: background: url("https://webapi.XXX.com/assets/default.png") 0% 0% / cover repeat scroll padding-box border-box rgba(0, 0, 0, 0); 需要转成 DataURL: background: url("data:image/png;base64,iVBOR...CYII=") 0% 0% / cover repeat scroll padding-box border-box rgba(0, 0, 0, 0); 需要注意的是节点的 background 属性可能存在多个 url(),所以通过 [RegExp.prototype.exec()](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec) 正则 API 找出出所有图片链接: function parseBackground(node) { const background = node.style.getPropertyValue('background'); // 正则规则 const URL_REGEX = /url\(['"]?([^'"]+?)['"]?\)/g; const result = []; let match; while ((match = URL_REGEX.exec(background)) !== null) { result.push(match[1]); } } 和上文 Img 标签链接处理方式类似,遍历处理 background 属性的 url 链接: function inlineUrl(node) { const background = node.style.getPropertyValue('background'); return Promise.resolve(background) .then(() => { const urls = parseBackground(node) let done = Promise.resolve(background); urls.forEach((url) => { done = done.then((background) => { return inline(background, url); }); }); return done; }); } function inline(background, url) { return Promise.resolve(url) .then(getImgDataURL) .then((dataUrl) => { // dataUrl替换原url链接 const res = background.replace(url, dataUrl); return res; }); } 最后重新赋值节点 background: function inlineBackground(node) { reurn inlineUrl(node) .then((inlined) => { node.style.setProperty( 'background', inlined, node.style.getPropertyPriority('background'), ); }) .then(() => node); } 水印 后台系统页面生成的 DOM 图片可能会涉及到安全等级的数据,基于公司信息安全考虑,图片需要添加水印。使用 canvas 绘制水印内容,然后利用 canvas.toDataURL() 生成水印图片,最后向目标节点添加包含水印背景的伪元素 style。 function createWaterMark(node, config) { const {content, alpha, fontSize, rotate} = config return new Promise((resolve) => { const canvas = document.createElement("canvas"); canvas.width = 250; canvas.height = 100; const ctx = canvas.getContext("2d"); if (ctx) { ctx.font = `${fontSize}px PingFang SC, sans-serif`; ctx.fillStyle = `rgba(0, 0, 0, ${alpha})`; ctx.textAlign = "left"; ctx.rotate((Number(rotate) * Math.PI) / 180); ctx.fillText(content, 50, 50); } // canvas生成水印图片dataURL const backgroundUrl = canvas.toDataURL(); // 定义水印类名 const className = `waterMarker${new Date().getTime()}`; // 节点添加类名 node.className = `${node.className} ${className}`; const style = document.createElement("style"); style.innerHTML = `.${className}::after{ ... background-image: url(${backgroundUrl}); }`; node.appendChild(style); resolve(node); }); } 序列化与绘制 克隆节点序列化为 XML 到目前为止,已经拿到了结构被深度复制且样式内联化处理的克隆节点。目标是使用 元素嵌入目标节点并使用 SVG 渲染出来,所以使用 XMLSerializer.serializeToString() 将节点序列化成一串 XML 字符串。 function createSvgDataUrl(node, width, height) { return Promise.resolve(node) .then((node) => { // 设置xmlns命名空间属性与SVG区分开 node.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml'); return new XMLSerializer().serializeToString(node); }) .then((xhtml) => { // 嵌入foreignObject return `<foreignObject x="0" y="0" width="100%" height="100%">${xhtml}</foreignObject>`; }) .then((foreignObject) => { return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">${foreignObject}</svg>`; }) .then((svg) => { return `data:image/svg+xml;charset=utf-8,${svg}`; }); } 嵌入节点 SVG 的 DataURL 数据: canvas 绘制 剩下的工作就交给 canvas 绘制,再转成 Blob 二进制,最后 Clipboard 写入剪贴板。 function drawDomImage(node) { return getSvgDataUrl(node).then((dataUrl) => { return new Promise((resolve, reject) => { const img = new Image(); img.src = dataUrl; img.onload = function () { const canvas = document.createElement('canvas'); canvas.width = getWidth(node); canvas.height = getHeight(node); canvas.getContext('2d').drawImage(img, 0, 0); canvas.toBlob(resolve); }; img.onerror = reject; }); }); } drawDomImage(target) .then(blob) => { navigator.clipboard.write([ new ClipboardItem({ [blob.type]: blob, }), ]) .then(() => alert("复制成功")), .catch(() => alert("复制失败")); }) .catch((err) => console.log(err)); 总结 本文通过分析如何将 DOM 复制到剪贴板的过程,介绍了 Clipboard API 的特性以及 DOM 生成图片的处理思路,并帮助大家熟悉克隆 DOM 元素和样式的方法。初衷是实现一键截屏模块功能,此功能看上去似乎没有比调用截图工具提升多少优化,但生成 DOM 图片的操作也可以应用在其它业务场景,例如:弹窗图片、制作海报、制作名片、二维码截屏等,希望本文对大家有所帮助。 参考资料 Clipboard API - MDN foreignObject - MDN RegExp.prototype.exec() | MDN

56
life.caoabout 4 years

怎样编写一条自定义 eslint 规则

怎样编写一条自定义 eslint 规则 背景 最近在使用 umi 的过程中发现一个问题,VSCode 自动导入 umi 模块时经常会从 .umi 目录导入,例如: import { useRequest } from '@/.umi/plugin-request/request'; 这种情况导入后在开发环境可以正常使用,但是打包发布之后则会因为没有.umi目录而报错。那有没有办法在开发阶段就发现并规避这个问题呢? 如果要在开发阶段发现问题,首先就考虑 eslint 是否可以满足我们的需求,当使用非法路径导入时,可以通过 eslint 给一个错误提醒,如果可以直接帮我们修复那就更好了。 查阅 eslint 插件文档后发现有这样一条规则: 'import/no-internal-modules': ['error', { forbid: ['**/.umi/**'] }] 这条规则会在匹配到禁用路径时报错,但是并没有提供修复功能。如果只是禁用路径的话这条规则已经可以勉强满足我们的需求了,但是这个时候想到另外一个类似的需求场景。 我们在项目中有时会重写一些外部模块,重写后我们期望所有的地方都统一使用重写后的模块,如果可以自动替换某个模块的导入路径,那么就可以统一这个模块的导入路径了。 明确需求 首先确定一下我们要实现的效果: 禁用 @/.umi 目录,并把相关目录自动转为 umi 禁用某个路径下的某个模块,例如我们重写了 react-router 的 Link 和 useHistory 模块,所以 import { useModel, Link } from 'umi'; 需要自动替换为 import { useModel, useHistory } from '``@/utils/router``'; 最终的在.eslitrc文件中的配置如下: 实现需求 初始化项目 eslint 为我们提供了一套脚手架可以快速创建插件: 全局安装 yo 和 generator-eslint yarn global add yo generator-eslint 新建一个空文件夹并执行 yo eslint:plugin 执行 yo eslint:rule 新建一条规则 使用脚手架新建一条规则后,会生成三个文件,分别对应文档、代码和测试: 编写规则文档 文档可以参考其他的 eslint 插件,按照建议的格式编写即可 例如:https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/no-absolute-path.md 编写测试用例 测试分为 valid 和 invalid 两部分,valid 是符合规则的代码,invalid 是不符合规则的代码,在 invalid 中写入所有我们需要处理的情况: const rule = require('../../../lib/rules/no-internal-modules'), RuleTester = require('eslint').RuleTester; const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 2020, sourceType: 'module' } }); const options = [ { target: '**/.umi/**', replace: 'umi' }, { target: 'umi', replace: '@/utils/router', modules: ['Link', 'useHistory'] } ]; ruleTester.run('no-internal-modules', rule, { valid: [ { code: "import { useRequest } from 'umi'", options }, { code: "import { useModel } from 'umi'", options }, { code: "import { useRequest, useModel } from 'umi'", options } ], invalid: [ { code: "import { useRequest } from '@/.umi/request-plugin/request';", errors: [{ messageId: 'replace-path', data: { target: '@/.umi/request-plugin/request', replace: 'umi' } }], options, output: "import { useRequest } from 'umi';" }, { code: "import { Link, useModel } from 'umi';", errors: [{ messageId: 'replace-module', data: { name: 'Link', replace: '@/utils/router' } }], options, output: "import { useModel } from 'umi';\nimport { Link } from '@/utils/router';" }, ] }); 编写代码 代码主要分为 meta 对象和 create 方法两个部分。 meta 对象主要是规则相关的配置,详情可查阅官方文档 create 方法接收一个 context 参数,context 对象有一些属性和方法可以使用,我们这里用到的主要有两个: context.options: eslint 规则配置传入的参数 context.report: 检测到错误之后报告的方法 create 方法返回一个对象 create (function) 返回一个对象,其中包含了 ESLint 在遍历 JavaScript 代码的抽象语法树 AST (ESTree 定义的 AST) 时,用来访问节点的方法。 如果一个 key 是个节点类型或 selector,在 向下 遍历树时,ESLint 调用 visitor 函数 如果一个 key 是个节点类型或 selector,并带有 :exit,在 向上 遍历树时,ESLint 调用 visitor 函数 如果一个 key 是个事件名字,ESLint 为代码路径分析调用 handler 函数 一个规则可以使用当前节点和它周围的树,报告或修复问题。 借助 AST Explorer 工具把我们的目标代码转换一下: 通过观察 AST 可以发现,我们需要处理的节点类型为 ImportDeclaration,在 create 方法中返回 key 为 ImportDeclaration 的 selector 即可捕获到相关代码: create(context) { return { ImportDeclaration: (node) => { // 拿到节点后,遍历在规则内配置的参数 context.options.forEach((option) => { // 通过 minimatch 把规则转换成正则表达式 const regexp = minimatch.makeRe(option.target); if (!regexp.test(node.source.value)) { return; } // 判断如果配置中包含 modules 参数,即为模块替换,否则为导入路径替换 if (option.modules) { reportModuleList(node, option); return; } reportPath(node, option); }); } }; } 在匹配到 ImportDeclaration 节点后,需要遍历我们配置的规则,判断规则中是否存在 modules 参数,当存在 modules 参数时,需要对 modules 内的模块的导入路径进行替换。 通过 context.report 可以上报错误,通过 fix 方法修复错误--先删除本次导入中对应的模块,再插入一行从正确路径导入此模块的代码: const reportModule = (spec, node, option, isLast) => { const moduleName = spec.imported.name == spec.local.name ? spec.imported.name : `${spec.imported.name} as ${spec.local.name}`; // 通过 context.report 即可上报错误 context.report({ node: spec.imported, messageId: 'replace-module', data: { name: moduleName, replace: option.replace }, // 通过 fix 方法修复错误,先删除本次导入中对应的模块,再插入一行从正确路径导入本模块的代码 fix: function (fixer) { return [ fixer.removeRange([spec.range[0], isLast ? spec.range[1] : spec.range[1] + 1]), fixer.insertTextAfter(node, `\nimport { ${moduleName} } from '${option.replace}';`) ]; } }); }; const reportModuleList = (node, option) => { node.specifiers.forEach((spec, index) => { if (spec.type !== 'ImportSpecifier') { return; } // 如果导入模块名称符合配置的规则,就替换此模块的导入路径 if (option.modules.includes(spec.imported.name)) { reportModule(spec, node, option, index === node.specifiers.length - 1); } }); }; 不存在 modules 参数时,直接替换导入路径即可: const reportPath = (node, option) => { context.report({ node: node.source, messageId: 'replace-path', data: { target: node.source.value, replace: option.replace }, fix: function (fixer) { return fixer.replaceText(node.source, `'${option.replace}'`); } }); }; 代码写完并执行测试通过后,就可以发布到 npm 仓库了 配置和使用 首先安装我们发布的 npm 包 yarn add eslint-plugin-import-path-plus --dev 把新安装的插件添加到 eslint 配置的 plugins 里面: { "plugins": [ "import-path-plus" ] } 把规则配置到 eslint 配置的 rules: { "rules": { 'import-path-plus/no-internal-modules': [ 'warn', { target: '**/.umi/**', replace: 'umi' }, { target: 'umi', replace: '@/utils/router', modules: ['Link', 'useHistory'] } ] } } 现在就可以看到效果了: 点击 Quick Fix 或者执行 eslint --fix ,就可以自动修复了。 总结 本文通过发现问题、分析问题、确定需求和实现需求的路径,最后通过编写 eslint 规则,解决了我们最初的问题。我们在项目中有时会重写一些外部模块,重写后我们期望所有的地方都统一使用重写后的模块,此时即可使用此规则,自动把相关模块的导入替换成内部路径。 引用链接 https://eslint.bootcss.com/docs/developer-guide/working-with-rules

107
life.caoabout 4 years

从React DevTools中发掘 ReactDOM 中的一些隐藏特性

从React DevTools中发掘 ReactDOM 中的一些隐藏特性 有过 React 经验的开发者可能都使用过 React DevTools。DevTools 提供了丰富的能力:展示组件树,组件的 props 与组件中 hook 的值。React Devtools 是如何检测当前网页是否使用 React 以及是如何获取组件相关的众多数据呢? React Devtools 的原理 打开 ReactDOM 代码时,用 devtools 为关键字搜索,你会发现许多与 React Devtools 相关的代码。 function injectInternals(internals) { if (typeof __REACT_DEVTOOLS_GLOBAL_HOOK__ === 'undefined') { // No DevTools return false; } var hook = __REACT_DEVTOOLS_GLOBAL_HOOK__; try { rendererID = hook.inject(internals); // We have successfully injected, so now it is safe to set up hooks. injectedHook = hook; } catch (err) { // ... } // DevTools exists } 在浏览器控制台输入 __REACT_DEVTOOLS_GLOBAL_HOOK__ 详细看一下这个对象。 这个对象十分复杂,以下的几个方法倒是很值得关注。 onCommitFiberRoot onCommitFiberUnmount onPostCommitFiberRoot 渲染阶段 从方法名称来看,上面这几个肯定是与 ReactDOM 的渲染密切关联了。ReactDOM 在特定的阶段会调用这些的方法,比如:onCommitFiberRoot。 function onCommitRoot(root, priorityLevel) { if (injectedHook && typeof injectedHook.onCommitFiberRoot === 'function') { try { // ... injectedHook.onCommitFiberRoot(rendererID, root, priorityLevel, didError); } catch (err) {} } } 正是借助 __REACT_DEVTOOLS_GLOBAL_HOOK__,React Devtools 便与 ReactDOM 建立起了联系,从而拥有获取组件众多信息的能力。 FiberRoot/FiberNode 在新的 React 架构下,会先把 Virtual DOM 转成 FiberNode,然后再渲染 FiberNode。onCommitFiberRoot 等这些方法中的传递的数据正是 FiberNode。FiberNode 的结构是比较复杂的,可以简化为如下的结构: interface ReactFiberRootNode { current: ReactFiberNode; } interface ReactFiberNode { tag: number; stateNode: null | HTMLElement; // dom 节点 memoizedProps?: Record<string, any>; // props memoizedState: ClassComponentState | HookLinkedQueue | null; // hooks child?: ReactFiberNode; sibling?: ReactFiberNode; return: ReactFiberNode; // parent // ... } 从上面的结构可以看出,FiberNode 包含了非常多与组件相关的信息。stateNode 为组件对应真实的 DOM 节点,memoizedProps 为组件的 props。当组件为函数式组件时,tag 为 0,memoizedState 保存了组件中的 hooks 信息。当组件为类组件是,tag 为 1,memoizedState 则是组件的 state。如下图所示,FiberNode 节点形成一个链表结构。 只要能获取组件对应的 FiberNode,我们便可以做到在运行期间以无侵入的方法获取组件的众多信息。比如:通过 FiberNode 进行遍历,实现 findNativeNodesForFiber 方法,用以查到找其对应的真实 DOM 节点。 function findNativeNodesForFiber(node?: ReactFiberNode) { // ... // 先遍历 child const { child } = node; collectStateNode(); // 再遍历所有的 sibling let current = child?.sibling; while (current) { collectStateNode(); current = current.sibling; } // ... } React Devtools 中审查元素功能正是基于类似的原理去实现。 memoizedState 与 React Hooks 上文中提到当组件为函数式组件时,memoizedState 保存了 React Hooks 相关的信息。与 FiberNode 类似,React Hooks 也形成一个链表。 export interface HookLinkedQueue { memoizedState: any; // 渲染时的值 next: HookLinkedQueue | null; // ... } React Hook 将其数据都保存在 memoizedState 上。比如对于 useRef 来说,ref.current 值就是 memoizedState。类似的,可以实现 inspectSomeHooksOfFiber 来获取组件内使用特定 hook 中保存的值。 function inspectRefHooksOfFiber(node: ReactFiberNode) { let current: HookLinkedQueue | null = node.memoizedState; while (current) { retrieveValue(current); current = current.next; } } 实践:突破 useDebugValue 的限制 useDebugValue 是 React 内置的一个 hook,用以在 React Devtools 中显示自定义 hook 的标签。它的限制是只能在 hook 中使用。借助前文介绍的知识点,我们可以实现一个增加版的 useDebugValue,你可以像普通的 hook 一样来使用它,没有其他限制。 useDebugValueAnywhere 的实现 为了与标准的 useDebugValue 区分,将之命名为useDebugValueAnywhere。其实现比较简单,name 表明数据的名称,用一个特殊的 ref 对象来存储 debug 相关的数据。 export function useDebugValueAnywhere(name: string, data: any) { const ref = useRef({ [DebugHookKey]: { name, data, }, }); // ... } 特定的 devtools 参考 React Devtools 的逻辑,在 __REACT_DEVTOOLS_GLOBAL_HOOK__ 中注入我们的 onCommitFiberRoot 方法,从而确保 ReactDOM 每次渲染时,能获取最新的 FiberNode。 currentHook.onCommitFiberRoot = function (...args) { handleCommitFiberRoot(...args); // 注入 oldOnCommitFiberRoot.apply(this, args); }; 接下来便是对 FiberNode 进行遍历。在遍历的过程中,检查每个 FiberNode 中 memoizedState 链表,检测组件的 hooks 中是否用到了 useDebugValueAnywhere。如果存在,就将值 FiberNode 与 hook 中的值收集起来。 { visitFiberNode(node?: ReactFiberNode) { if (!node) return; this.inspectFiber(node); this.visitFiberNode(node.child); let { sibling } = node; while (sibling) { this.visitFiberNode(sibling); sibling = sibling.sibling; } } } 剩下的工作就是考虑以何种形式去展示收集到的 debug 信息。在 PC 端可以直接输出数据到控制台;在移动端 vConsole 使用较多,那么就可以基于 vConsole 开发一个插件,实现一个极简版的 React Devtools,专门用以展示这些信息。 总结 本文通过剖析了 React Devtools 的原理,介绍隐藏在 ReactDOM 中的一些特性,并带领大家熟悉了一下 React Fiber 架构。基于上述原理,可以开发一个增加版的 useDebugValue。由于本文介绍的特性并非公开的 API,没有兼容性。当 React/ReactDOM 版本升级时,可能还需要再做适配,因此只适合用来开发 Devtools 之类的工具,不推荐业务开发使用。

3
life.caoabout 4 years

代码重构之理论和实践

代码重构之理论和实践 主要改造点 重构的每个步骤都很简单,甚至显得有些过于简单:只需要把某个字段从一个类移到另一个类,把某些代码从一个函数拉出来构成另一个函数,或是在继承体系中把某些代码推上推下就行了。但是,聚沙成塔,这些小小的修改累积起来就可以根本改善设计质量。这和一般常见的“软件会慢慢腐烂”的观点恰恰相反。——《重构:改善既有代码的设计》 本文结合《重构:改善既有代码的设计》中的观点,从本次重构的项目出发,分别从神秘代码、过长函数、全局变量、响应拦截和链路过长五个方面来讲述。 神秘代码 代码必须明确说出你的意图,而且必须富有表达力。这样可以让代码更易于被别人阅读和理解。代码不让人迷惑,也就减少了发生潜在错误的可能。一言以蔽之,代码应意图清晰,表达明确。——《高效程序员的45个习惯:敏捷开发修炼之道》 这里的神秘代码指的就是魔数(magic number),是指代码中出现的没有说明用途的数字。根据编程中的 PIE (Program Intently and Expressively)原则,代码必须明确地表达自己的意图。很明显,魔数便是 PIE 原则的反面例子,这些谜一样的存在数字会给编程人员造成很大的阅读成本,进一步会导致程序出错,再而增加维护成本,而解决魔数的方式其实很简单。所以把代码中魔数处理掉,是一件收益远大于成本的事情,值得我们去做。 下面我们来看本次重构中出现的魔数: const unPassList = [4, 5, 6, 7, 9]; const needTaxiUploadList = [2, 18, 19]; 看到上面的代码我猜你肯定会很疑惑,这些数字到底是什么意思? 我们先了解下背景,首先司机注册流程是分步骤的,司机可以在注册流程中任意一步退出然后再登录,所以在代码设计的时候便会不同的数字来代表不同的注册步骤,如“注册中”、“驳回重传”、“补充资料”等等,这是出现魔数的第一种情况。 其次,司机注册需要上传各种证件,如“身份证”、“驾驶证”等等,这样代码中又需要使用不同的数字来代表这些证件。这是第二种情况。当然不要忘了,一般接口返回的错误码也是数字,这可以说是第三种情况。 最后这样多种情况结合在一起,便出现了一个数字有多个含义的情况。比如数字“4”,可能代表着“已经填写了身份信息”这一注册状态,也可以是“身份证”这一证件类型,还可以是“人脸验证通过”这一状态。 类似以上的代码在这次改造前的代码中随处可见,大多都是比较简单的语句,但是个中含义却让人捉摸不透,即使能明白数字的含义也很难理清。 基于这些原因,这些数字有必要统一收口到枚举中来管理,如 export enum RegisterStatusEnum { PASS = 1, // 申请通过 PHONE_PASS = 2, // 手机认证通过 // ... } 使用时直接从枚举中读取 const unPassList = [ RegisterStatusEnum.FILLED_ID_CARD, RegisterStatusEnum.DESTORY, // ... ]; 这样处理的好处是使代码更加简单明了,增加可读性,不至于让人感到困惑,其次也便于代码的管理,后续如果枚举值有变化,只需在枚举中修改一处即可,不用每个数字都去改,增加开发效率。 过长函数 我们应该遵循这样一条原则:每当感觉需要以注释来说明点什么的时候,我们就把需要说明的东西写进一个独立函数中,并以其用途(而非实现手法)命名。我们可以对一组甚至短短一行代码做这件事。哪怕替换后的函数调用动作比函数自身还长,只要函数名称能够解释其用途,我们也该毫不犹豫地那么做。关键不在于函数的长度,而在于函数“做什么”和“如何做”之间的语义距离。——《重构:改善既有代码的设计》 这里过长函数指的就是在一个函数体内堆积了大量的代码,而这样的代码往往都是可以拆成一个个小函数的。从最浅显的角度来看,如果一个函数的长度超出了电脑屏幕的高度,我们就需要不断地上下移动来查看这个函数,这就已经给我们的开发者造成很不好的体感了,更不必谈设计模式和编程原则了。这样的函数往往都是逻辑混乱,甚至还有可能藏着 bug 在里面,是有很大的潜在风险的。所以以后看到这样的函数,就毫不犹豫地对它做一次优化吧。 罗伯特·C·马丁(Robert C. Martin)在《敏捷软件开发:原则、模式与实践》中提出了单一职责原则(又称单一功能原则),结合这一原则,我们可以认为,一个函数应该只有一个引起变化的原因,应该只有一个职责。如果一个函数拥有过多职责,那么这个函数至少存在两个缺点。第一,任意一个职责的变化都可能削弱这个函数的能力;第二,对于使用者而言,为了使用函数中的其中一个职责,不得不把其他职责也包含进来,导致代码冗余,性能浪费。 下面我们来看本次重构中出现的过长函数: async submit(needValidation = true) { // do something... // 大量的 if 语句... if (this.step == 5) { parameters.city_id = this.registerCityObj.cityId; parameters.zone_id = this.registerCityObj.zoneId; } if (this.step == 3 || this.step == 4) { parameters.vehicle_id = this.vehicleId; parameters.vehicle_body_color = '黑色'; } if (...){ // ... } const parameters = { ...parameters, ...this.value, }; const { data, ret } = await (this.registered ? fetchUpdateDriverInfo(parameters) : fetchSubmitJoinData(parameters)); // do something... } 上面的这个函数,我们可以很明显地看到很多 if 语句,这是需要警惕的地方,多个 if 语句的出现,往往就说明了这些语句正在形成各自的职责,正在瓜分函数的能力。这也是上面所提到的第二个缺点,只要调用这个函数,不论是谁,都必须先经过这些 if 语句,造成不必要的浪费。 而且这个函数多达一百六十多行,读起来相当费劲,想要明白这个函数所处理的事情需要花一段时间。所以,有必要对这个函数进行拆解。 下面我们通过提炼函数的方式来拆解这个函数 @BeforeCheck(() => !isSubmitting) private async handleSubmit() { // do something... // 提取的函数,对用户输入的值做格式化处理 this.handleFormatParams(); const parameters: IFetchSubmitJoinParams = { driver_id: String(this.driverId), step: this.step, // 提取的函数,对需要上传的图片做格式化处理 photo_item: this.handleFormatPhotoItemParams(), ...this.value, }; const { data } = await (this.registered ? fetchUpdateDriverInfo(parameters) : fetchSubmitJoinData(parameters)); // do something... } 通过提炼函数的方式,提炼出 handleFormatParams 和 handleFormatPhotoItemParams 方法,我们把一段一百多行的函数缩减为不到五十行。但提炼出来的函数中还充斥着大量的条件语句,这个我们放到下面全局变量中来说。 全局变量 伴随全局变量的常见问题 : 对全局数据的疏忽改变。你可以会在某处改变全局变量的值,而在别处又会错误地以为它仍保持着原值,这就是所谓的副作用。 全局变量会损害模块性和可管理性。开发大于几百行规模软件的一个主要问题便是管理的复杂性,使其成这可管理的唯一办法便是将程序成为几个部分,以便每次只考虑其中的一个部分。模块化便是将程序分为几部分的最有力工具之一。 ——《代码大全》 这里的全局变量指的是 Vue 中的 mixin,严格来说,mixin 也不算全局变量,但它有着和全局变量类似的缺点,甚至有过之而无不及。mixin 的存在让 Vue 实例被切割成两部分,两者之间可以轻易的互相调用。我自己就有被 mixin 教训过的经历,有一次在查一个页面获取的缓存值的问题,页面本身的存取逻辑没什么问题,浏览器的本地缓存也不应该会出什么问题,可为什么缓存里的值和预期的不一样呢?后来才发现,缓存用到的值在 mixin 中被改变了。这就是所谓的副作用。 下面来看下本次重构中的部分 mixin 代码: created() { // do something... }, watch: { value: { handler: function(value) { // do something... }, deep: true, }, }, 在这个 mixin 中有生命周期函数以及 watch 监听。生命周期函数和 watch 具有自执行的能力,尤其是 watch,这里 watch 所监听的 value,是存放在各个具体的 Vue 实例中的,也就是说,任意一个 Vue 实例都可能不知道数据被改变了,因为数据是在 mixin 的 watch 中改变的。这些都是导致代码逻辑复杂混乱的重要原因。同时也如上面过长函数例子中看到,由于 mixin 需要作用于多个页面,从而增加了大量的条件语句。 本次重构这里我们把各个页面需要用到 mixin 中的变量和函数还原到页面本身中,从而阻止了各个页面之间的相互污染,同时也可以处理掉多数的条件语句。 来看下面一组对比 原 mixin 中对请求参数处理的代码: if (this.photoValue) { // do something... for (const key of Object.keys(this.photoValue)) { // do something... if (value) { // do something... if (this.photoType[key] == 40) { // do something... } if (this.photoType[key] == 43) { // do something... } // if 语句省略... } } if (this.step == 20 ) { // do something... } // do something... } // 提交的文本信息 if (this.value) { for (const key of licenseIDArray) { if (this.value[key]) { // do something... } } // do something } if (this.step === 20 && !this.registered) { // do something... } // ... } 提炼函数放到页面本身中的请求参数处理代码: private handleFormatPhotoItemParams() { const photoItem = []; for (const key of Object.keys(this.photoValue)) { const value = this.photoValue[key]; if (value) { // do something... } } return JSON.stringify(photoItem); } private handleFormatParams() { this.value.driver_license = this.value.driver_license.toUpperCase(); } 通过这种方式,我们把代码变得更简洁,逻辑更清晰,各个页面之间相互独立,不会造成不必要的污染。 响应拦截 如果你在一个以上的地点看到相同的代码结构,那么可以肯定:设法将它们合而为一,程序会变得更好。——《重构:改善既有代码的设计》 这里的响应拦截所具体所体现的是代码重复问题。重复的代码降低我们的工作效率,所谓牵一发而动全身,一旦数据格式有改变,所有的重复代码都得改一次。 我们来看其中一个请求接口的函数: async fetchLunaImg(photoList) { const { error, data } = await fetchLunaImg(parameters); if (error || data.ret != 0) { return this.$toast.fail((data && data.msg) || '网络错误'); } // do something } 上面代码是改造前的请求接口函数的通用格式,每个请求都会单独对错误码进行处理,这次改造我们把对错误码的处理放到响应拦截器中,减少这些不必要的重复代码。需要对个别错误码进行处理的,我们放到 catch 中处理。以下是改造后的代码 // 添加响应拦截器 instance.interceptors.response.use( (response) => { // do something... }, (error) => { Toast.clear(); const config = (error.response || {}).config || {}; if (config.showError !== false) { Toast(error.message || '系统错误'); } return Promise.reject(error); }, ); // 接口调用 private async handleSubmit() { try { await fetchSubmitJoinData(parameters)); // do something... } catch (error: any) { // 对 15008 错误码单独处理 if (error.response.data.ret === 15008) { // do something... } } 链路过长 如果你看到用户向一个对象请求另一个对象,然后再向后者请求另一个对象,然后再请求另一个对象......这就是消息链。在实际代码中你看到的可能是一长串的取值函数或一长串临时变量。采取这种方式,意味客户端代码将与查找过程中的导航结构紧密耦合。一旦对象间的关系发生任何变化,客户端就不得不作出相应的修改。——《重构:改善既有代码的设计》 这里的链路过长所反映的是在不同组件之间调用路径过长的问题,这种情况导致了组件之间的耦合度过高,一旦组件之间的调用关系发生改变,整条消息链都有可能崩溃。 以上传证件为例子,先来看看改造前的流程 点击上传图片,imgUpload 组件触发父页面的 clickUpload 方法; 父页面改变 activeImageId 值; documentUpload 组件根据 activeImageId 显示对应的示例图片; 点击 documentUpload 中的上传图片,触发父页面 mixin 中的 upload方法; mixin 中的 upload方法通过 ref 的方式调用 imgUpload 中的 upload 方法; imgUpload 调用相机或选择图片上传; 图片上传成功后,触发父页面的 change 方法和 ocrImg 方法; 父页面通过 change 更新图片路径,通过 ocrImg 进行 ocr 识别。 // 1. 点击上传图片,imgUpload 组件触发父页面的 clickUpload 方法 clickUpload() { this.$emit('clickUpload'); } // 2. 父页面改变 activeImageId 值 // 3. documentUpload 组件根据 activeImageId 显示对应的示例图片 // 4. 点击 documentUpload 中的上传图片,触发父页面 mixin 中的 upload方法 upload() { this.$emit('upload', this.type, 'camera'); }, // 5. mixin 中的 upload方法通过 ref 的方式调用 imgUpload 中的 upload 方法 upload(type, action) { this.$refs[this.activeImageId][0].upload(); } // 6. imgUpload 调用相机或选择图片上传 upload(type, action, imageId) { this.appUploadImg(action); }, // 7. 图片上传成功后,触发父页面的 change 方法和 ocrImg 方法 async uploadImg(base64) { this.$emit('change', img); // 更新图片路径 this.$emit('ocrImg'); // 发起OCR识别请求 }, // 8. 父页面通过 change 更新图片路径,通过 ocrImg 进行 ocr 识别 async ocrImg(item) { const { data } = await fetchOCRinfo(parameters); }, 通过观察发现,链路过长的原因是图片上传组件和图片示例组件分开了,所以我们这两个组件合到一起,缩短调用链路,减少不必要的跳转。 下面是改造后的流程 点击上传图片,imgUploader 组件显示对应的示例图片; 点击 imgUploader 中的上传图片,调用相机或选择图片上传; 图片上传成功后,触发父页面的 handleChangePhoto 方法; 父页面更新图片路径,并调用 ocr。 // 1. 点击上传图片,imgUploader 组件显示对应的示例图片 private handleShowSample() { this.showSample = true; } // 2. 点击 imgUploader 中的上传图片,调用相机或选择图片上传 private handlePictureAction() { // do something... } // 3. 图片上传成功后,触发父页面的 handleChangePhoto 方法 @Emit('changeValue') private async handleUploadImg(base64: string) { const params = { code_base64: encodeURI(base64), }; const { data } = await fetchImgUpload(params); // do something... return data.file_name; } // 4. 父页面更新图片路径,并调用 OCR private handleChangePhoto(id: string, value: string) { this.photoValue[id] = value; this.handleOcr(id); } 通过流程图就可以清晰地看到,改造后比改造前的链路要短了许多,这不仅有利于我们对代码的理解,也为后续可能发生改变的迭代先除去不少坑。 总结 以上就是本次重构的主要内容,分别从神秘代码、过长函数、全局变量、响应拦截和链路过长五种情况来讲述,这五种情况主要立足于代码的可阅读性、可维护性(可管理性)、代码性能和开发效率四个方面来展开。 重构可以很大,本次重构引入了 Typescript,这对整个代码框架都会有影响,重构也可以很小,小到对文件名做了统一的规范。大有大做,小有小做。切勿因为太小而觉得不值得做,所谓合抱之木,生于毫末;九层之台,起于累土。小小的重构堆积起来也可以从根本上改善代码的质量。切勿因为太大而不敢去做,所谓不破不立,破而后立,一次大的重构可以让代码脱胎换骨,一次好的重构足以让代码披坚执锐,上阵杀敌。 最后引用唐朝神秀禅师一偈语与大家共勉:时时勤拂拭,莫使惹尘埃。 参考 [1] (美)马丁·福勒(Martin Fowler). 重构:改善既有代码的设计:第2版[M].熊节,林从羽. 北京:人民邮电出版社,2019. [2] Steve McConnell. 代码大全[M].天奥. 北京:学苑出版社,1993. [3] (美)Andy Hunt,Venkat Subramaniam. 高效程序员的45个习惯:敏捷开发修炼之道[M].钱安川,郑柯. 北京:人民邮电出版社,2010.x

1
life.caoover 4 years

React 18 新增的几种hooks

React 18 新增的几种hooks React 18 已经发布好几个月了,在这篇文章中,我们来聊一下 React 18 中新增的那些 hooks,以及它们的使用场景。 useTransition 卡顿问题 开发前端应用的过程中,有时点击按钮或者在输入框输入内容这样一个很小的操作就会导致视图的大量更新,这些更新同时进行时就可能导致页面的卡顿。如果我们能够将视图更新按紧急程度区分开来,让紧急的更新先完成渲染,不紧急的更新后完成渲染,就可以解决页面渲染卡顿的问题。 transition 就是 React 用来区分紧急更新和非紧急更新的一个新概念。 紧急更新(urgent updates)反映直接的交互,比如输入、点击、长按等。 非紧急更新(transition updates)将 UI 从一个视图切换到另一个视图。 比如下面这个例子: const allItems = Array(5e6) .fill(null) .map((_, index) => index + 1); function filterItems(value: string) { let matchedItems; if (!value) { matchedItems = allItems; } else { matchedItems = allItems.filter((item) => String(item).includes(value)); } return matchedItems.slice(0, 100).map((item) => `item ${item}`); } function ItemList({ list }: { list: string[] }) { return ( <ul> {list.map((item) => ( <li key={item}>{item}</li> ))} </ul> ); } export default function UseTransitionDemo() { const [value, setValue] = useState(''); const listNode = useMemo(() => { const items = filterItems(value); return <ItemList list={items} />; }, [value]); const handleChange: InputProps['onChange'] = (e) => { setValue(e.target.value); }; return ( <div> <Input value={value} onChange={handleChange} /> {listNode} </div> ); } 页面上有一个输入框,在输入内容时需要做大量的计算来决定页面需要显示的内容。输入效果如下图,输入过程中,页面就发生卡顿,输入框的内容也没有即时地显示出来。输入结束后一段时间,页面的更新才渲染出来。 然后我们将上面的例子使用 useTransition 修改一下: export default function UseTransitionDemo() { const [value, setValue] = useState(''); const [filterValue, setFilterValue] = useState(''); const [isPending, startTransition] = useTransition(); const listNode = useMemo(() => { const items = filterItems(filterValue); return <ItemList list={items} />; }, [filterValue]); const handleChange: InputProps['onChange'] = (e) => { setValue(e.target.value); startTransition(() => { setFilterValue(e.target.value); }); }; return ( <div> <Input value={value} onChange={handleChange} /> <div>isPending: {String(isPending)}</div> {listNode} </div> ); } 输入框内容的更新 setValue(e.target.value) 保持不变,而会导致大量计算的过滤值的更新用 startTransition 包起来标记为非紧急更新。输入效果如下,输入框内容的更新将会即时显示,页面也不再有卡顿的感觉。 异步加载时的用户体验问题 考虑下面的例子: const delay = (ms: number) => { return new Promise((resolve) => { setTimeout(() => { resolve(null); }, ms); }); }; const TabA = lazy(() => delay(1000).then(() => import('./tab-a'))); const TabB = lazy(() => delay(1000).then(() => import('./tab-b'))); export default function SuspenseTransition() { const [tab, setTab] = useState<'a' | 'b'>('a'); const handleSetTab: RadioGroupProps['onChange'] = (e) => { setTab(e.target.value); }; return ( <div> <div> <Radio.Group optionType='button' value={tab} onChange={handleSetTab}> <Radio value={'a'}>tab a</Radio> <Radio value={'b'}>tab b</Radio> </Radio.Group> </div> <Suspense fallback={<Spin />}> <div>{tab === 'a' ? <TabA /> : <TabB />}</div> </Suspense> </div> ); } 页面上有一个 tabs,每个 tab 下面的内容都是一个异步加载的组件(为了视觉效果,异步组件的加载加了 1 s 延迟)。 从 tab a 切换到 tab b 时,在 TabB 组件异步加载的过程中,Suspense 会显示 fallback 的内容 Spin。这从逻辑上没有问题,但是在 TabB 组件异步加载的过程中,如果页面上能够继续显示 TabA 的内容,会有一个更好的用户体验。 useTransition 的另一个用途就是基于 transition 中的更新不会导致重新 suspended 的内容显示 fallback。用 transition 改造后的示例如下: export default function SuspenseTransition() { const [tab, setTab] = useState<'a' | 'b'>('a'); const [isPending, startTransition] = useTransition(); const handleSetTab: RadioGroupProps['onChange'] = (e) => { startTransition(() => { setTab(e.target.value); }); }; return ( <div> <div> <Radio.Group optionType="button" value={tab} onChange={handleSetTab}> <Radio value={'a'}>tab a</Radio> <Radio value={'b'}>tab b</Radio> </Radio.Group> </div> <Suspense fallback={<Spin />}> <div style={{ opacity: isPending ? 0.5 : 1 }}> {tab === 'a' ? <TabA /> : <TabB />} </div> </Suspense> </div> ); } tab 切换效果如下,从 tab a 切换到 tab b 时,在 TabB 组件加载完成之前可以一直显示 TabA 的内容。 useDeferredValue useDeferredValue 接收一个值,返回这个值的一个备份。在组件更新时,这个备份的最新值将延迟到紧急更新完成后才生效。也就是说,一个紧急更新导致的组件重新渲染,useDeferredValue 会先返回一个旧值来完成渲染,在紧急更新完成后,才会使用新值来重新渲染。 useDeferredValue 和 useTransition 能够实现的效果是相似的,都是用来让紧急更新先完成,让非紧急更新后完成。 将前面使用 useTransition 解决卡顿问题的例子改为用 useDeferredValue 来实现如下: export default function UseDeferredValueDemo() { const [value, setValue] = useState(''); const deferredValue = useDeferredValue(value); const listNode = useMemo(() => { const items = filterItems(deferredValue); return <ItemList list={items} />; }, [deferredValue]); const handleChange: InputProps['onChange'] = (e) => { setValue(e.target.value); }; return ( <div> <Input value={value} onChange={handleChange} /> {listNode} </div> ); } useId useId 可以用来在服务端和客户端生成稳定的唯一 id,解决 hydration 过程中 id 不匹配问题。 简单的使用案例如下: function UseIdDemo() { const id = useId(); return ( <> <label htmlFor={id}>Do you like React?</label> <input id={id} type="checkbox" name="react"/> </> ); }; 当组件中需要使用多个 id 时,将 useId() 生成的 id 作为前缀即可: function UseIdDemo() { const id = useId(); return ( <div> <label htmlFor={id + '-firstName'}>First Name</label> <div> <input id={id + '-firstName'} type="text" /> </div> <label htmlFor={id + '-lastName'}>Last Name</label> <div> <input id={id + '-lastName'} type="text" /> </div> </div> ); } useSyncExternalStore useSyncExternalStore 是用来订阅和读取外部数据源的 hooks,并且兼容并发渲染。一般是用来给库的作者使用的,比如 react-redux。 关于为什么需要这样一个 hooks,考虑下面这个外部数据源: type Store<T> = ReturnType<typeof createStore<T>>; export function createStore<T>(initialState: T) { let state = initialState; const getState = () => state; const listeners = new Set<() => void>(); const setState = (fn: T | ((v: T) => T)) => { if (typeof fn === 'function') { state = (fn as (v: T) => T)(state); } else { state = fn; } listeners.forEach((l) => l()); }; const subscribe = (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; }; return { getState, setState, subscribe }; } export function useStore<T, S>(store: Store<T>, selector: (v: T) => S) { const [, forceUpdate] = useReducer(v => (v + 1), 0); useEffect(() => { const callback = () => forceUpdate(); return store.subscribe(callback); }, [store, selector]); return selector(store.getState()); } 我们用这个外部数据源来存储 input 的输入内容,然后在页面上将这个输入内容显示 10 次。这时候是没有任何问题的。 const store = createStore(''); function BlockText() { const text = useStore( store, useCallback((v) => v, []) ); return <div>{text}</div>; } function InputText() { const handleChange = (e: ChangeEvent<HTMLInputElement>) => { store.setState(e.target.value); }; return <input onChange={handleChange} /> } export default function UseSyncExternalStoreDemo() { return ( <div> <InputText></InputText> {Array(10) .fill(null) .map((_, index) => ( <BlockText key={index}></BlockText> ))} </div> ); } 但是当我们使用 startTransition 来开启并发渲染时(为了更好的演示渲染过程被中断的情况,我们在 BlockText 组件中加上 sleep(30) 来增加渲染的耗时): const store = createStore(''); function sleep(ms: number) { const start = performance.now(); while (performance.now() - start < ms) {} } function BlockText() { const text = useStore( store, useCallback((v) => v, []) ); sleep(30); return <div>{text}</div>; } function InputText() { const handleChange = (e: ChangeEvent<HTMLInputElement>) => { startTransition(() => { store.setState(e.target.value); }); }; return <input onChange={handleChange} /> } 此时输入内容时 UI 的效果如下,可以看到 10 行内容显示有明显的不一致问题。 useSyncExternalStore 可以用来解决上面的不一致问题。 我们使用 useSyncExternalStore 来重写外部数据源的 useStore 即可, 这会强制 store 变更的同步完成,解决上面的不一致问题。 export function useStore<T, S>(store: Store<T>, selector: (v: T) => S) { return useSyncExternalStore( store.subscribe, useCallback(() => selector(store.getState()), [store, selector]) ); } useInsertionEffect useInsertionEffect 是给 CSS-in-JS 库添加样式使用的,可以解决渲染时添加样式的性能问题。 function useCSS(rule) { useInsertionEffect(() => { if (!isInserted.has(rule)) { isInserted.add(rule); document.head.appendChild(getStyleForRule(rule)); } }); return rule; } function Component() { let className = useCSS(rule); return <div className={className} />; } 参考文档 https://reactjs.org/docs/hooks-reference.html https://reactjs.org/blog/2022/03/29/react-v18.html https://github.com/reactjs/rfcs/blob/main/text/0212-react-18.md https://github.com/reactwg/react-18/discussions/41 https://github.com/reactjs/rfcs/blob/main/text/0213-suspense-in-react-18.md https://github.com/reactwg/react-18/discussions/69

31
life.caoover 4 years

图片处理在前端项目中的实践,包含涂抹、裁切、打码等

图片处理在前端项目中的实践,包含涂抹、裁切、打码等 方案 避免重复造轮子,首选现成的开源工具。大致有两种类型: 集涂抹、裁切、滤镜、文字编辑等功能于一体的多合一工具库,如 tui.image-editor。 单一打码功能小工具 image-mosaic 综合考虑功能需求、界面交互风格、库代码量、接入复杂度,选择单一打码功能的小工具,源码不到200 行,非常轻量,无需查阅复杂文档,有问题也方便排查处理。 原理简介 由于后续整体功能的实现需要对库源码稍作调整,此处先梳理下所用库的实现原理,简单描述就是将待处理的图片绘制到 canvas 上,然后将整个画布分为若干行、列,每个行列交叉形成一个小方块,当选中指定区域的小方块时,读取小方块内所有像素点的 rgba 信息进行均值计算,得到新的 rgba 值用于绘制小方块区域,从而形成马赛克图样。 接着看看库源码中几个比较核心的方法实现: 前置了解:canvas 上绘制的图像,可以通过其 context 的 getImageData 方法获取到像素点信息,得到的是一组数组形式的数据,每个像素点的 rgba 属性在数组中连续列出, 所以对像素点进行处理时,遍历像素数组每四个遍历项即得到一个像素点的 rgba 值。 初始化数据 在 class Mosaic 的 constructor 中,通过实例化时传入 canvas 的 context 和马赛克方块( tile )宽高尺寸,对整个画布按 tile 的像素宽高为基本单位划分行、列,每一个行列的交叉点就是一个 tile, 对于每一个 tile,使用对象对其进行描述,属性包括 tile 所在行、列、像素宽高以及对应 tile 在画布上的像素集合。最终得到实例属性 tiles,一个包含了所有的 tile 对象的数组。 class Mosaic { constructor(context, { tileWidth = 10, tileHeight = 10, brushSize = 3 } = {}) { const { canvas } = context; this.context = context; this.brushSize = brushSize; this.width = canvas.width; this.height = canvas.height; this.tileWidth = tileWidth; this.tileHeight = tileHeight; const { width, height } = this; this.imageData = context.getImageData(0, 0, width, height).data; this.tileRowSize = Math.ceil(height / this.tileHeight); this.tileColumnSize = Math.ceil(width / this.tileWidth); this.tiles = []; // All image tiles. // Set tiles. for (let i = 0; i < this.tileRowSize; i++) { for (let j = 0; j < this.tileColumnSize; j++) { const tile = { row: i, column: j, pixelWidth: tileWidth, pixelHeight: tileHeight, }; if (j === this.column - 1) { // Last column tile.pixelWidth = width - (j * tileWidth); } if (i === this.row - 1) { // Last row tile.pixelHeight = height - (i * tileHeight); } // Set tile data; const data = []; const pixelPosition = this.width * 4 * this.tileHeight * tile.row + tile.column * this.tileWidth * 4; for (let i = 0, j = tile.pixelHeight; i < j; i++) { const position = pixelPosition + this.width * 4 * i; data.push.apply(data, this.imageData.slice(position, position + tile.pixelWidth * 4)); }; tile.data = data; this.tiles.push(tile); } } } ... } 根据 tile 绘制改变像素 根据 tiles 进行绘制,每一个 tile 的 data 中存储着图片在对应区块的像素点信息,绘制操作时,使用均值处理的方式,把每个 tile 中的像素点的 rgba 值分别加总,然后除以 tile 的像素点数,得到平均的 rgba 值用于填充对应 tile 的像素区域,这样的计算方式简单,新覆盖的颜色值跟原始色块的差距不会很大,修改后画面看起来不会很突兀。 class Mosaic { // ... drawTile(tiles) { tiles = [].concat(tiles); tiles.forEach((tile) => { if (tile.isFilled) { return false; // Already filled. } if (!tile.color) { let dataLen = tile.data.length; let r = 0, g = 0, b = 0, a = 0; for (let i = 0; i < dataLen; i += 4) { r += tile.data[i]; g += tile.data[i + 1]; b += tile.data[i + 2]; a += tile.data[i + 3]; } // Set tile color. let pixelLen = dataLen / 4; tile.color = { r: parseInt(r / pixelLen, 10), g: parseInt(g / pixelLen, 10), b: parseInt(b / pixelLen, 10), a: parseInt(a / pixelLen, 10), }; } const color = tile.color; this.context.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a / 255})`; const x = tile.column * this.tileWidth; const y = tile.row * this.tileHeight; const w = tile.pixelWidth; const h = tile.pixelHeight; this.context.clearRect(x, y, w, h); // Clear. this.context.fillRect(x, y, w, h); // Draw. tile.isFilled = true; }); } // ... } 通过指定坐标点得到对应区域 tile 在实际操作中,用户通常通过鼠标点触或连续滑动来进行指定区域的涂抹,那么就需要能够根据鼠标在画布上操作的坐标点得到对应区域的 tile,然后通过前面介绍的 drawTile 方法来绘制。结合初始化实例传入的画笔尺寸 brushSize,即可得到不同起始行、列所围区域的一组 tiles。 class Mosaic { ... getTilesByPoint(x, y, isBrushSize = true) { const tiles = []; if (isBrushSize) { /** 纵坐标值除以tileHeight得到截至该坐标的行数 *再减去brushSize的一半得到起始行数 *与0比较是边缘情况的处理,防止计算结果小于最小起始行 **/ let brushSize = this.brushSize; let startRow = Math.max(0, Math.floor(y / this.tileHeight) - Math.floor(brushSize / 2)); let startColumn = Math.max(0, Math.floor(x / this.tileWidth) - Math.floor(brushSize / 2)); let endRow = Math.min(this.tileRowSize, startRow + brushSize); let endColumn = Math.min(this.tileColumnSize, startColumn + brushSize); // Get tiles. while (startRow < endRow) { let column = startColumn; while (column < endColumn) { tiles.push(this.tiles[startRow * this.tileColumnSize + column]); column += 1; } startRow += 1; } } return tiles; } ... } 擦除还原 从前面的介绍可以知道,每个 tile 的 data 中存有对应区块的原始像素信息,擦除时取出原始信息,使用context 的 createImageData 方法创建新的像素对象,然后将原始像素信息赋值其中,再使用putImageData 方法绘制回画布中,从而达到橡皮擦还原的效果,将所有被操作过的 tile 传入其中处理,即可还原整个画布上的马赛克。 class Mosaic { // ... eraseTile(tiles) { [].concat(tiles).forEach((tile) => { const x = tile.column * this.tileWidth; const y = tile.row * this.tileHeight; const w = tile.pixelWidth; const h = tile.pixelHeight; var imgData = this.context.createImageData(w, h); tile.data.forEach((val, i) => { imgData.data[i] = val; }) this.context.clearRect(x, y, w, h); // Clear. this.context.putImageData(imgData, x, y); // Draw. tile.isFilled = false; }); } ... ... } 扩展问题 从上面的原理介绍中可以看出,所用小工具已经能满足我们所需要的核心打码功能,但是距离完成完整的目标功能还需进一步扩展,比如实际场景中用户编辑一张图片需要对不同地方使用不同尺寸的马赛克方块,比如汽车尾部喷漆式的大号车牌字体,用小马赛克方块涂抹会导致整体看起来还是可辨认的轮廓,而对于证件照片上的小字体,又需要使用比较小的笔触来涂抹,而笔触尺寸是一开始初始化实例时传入的参数,那么要如何在编辑同一张图片时动态切换不同尺寸笔触呢? 尝试直接以新尺寸参数重新 new 实例 changeSize(value) { const canvas = this.$refs.canvas const ctx = canvas.getContext('2d') const newInstance = new Mosaic(ctx, { tileWidth: value, tileHeight: value, }) this.mosaic = newInstance } 结果是可行的,但存在两个擦除还原问题: 由于擦除功能需要依赖对应的 tile 中存储的像素信息做还原,新 new 的实例的 tiles 中没有原来的像素信息,会导致切换笔触后无法还原切换笔触前产生的马赛克,所以继续修改,重新 new 实例时将新旧实例的 tiles 合并 newInstance.tiles = newInstance.tiles.concat(this.mosaic.tiles) 原始的还原方法实现中,是以实例的 tileWidth、tileHeight 属性进行坐标计算,那么当新实例执行还原计算时,以新实例的 tileWidth 等属性计算坐标,遍历使用的却是旧实例的 tile,会产生错乱,只需修改原始实现的两处取值为 tile 自身存储的属性即可解决: eraseTile(tiles) { [].concat(tiles).forEach((tile) => { + const x = tile.column * tile.pixelWidth; + const y = tile.column * tile.pixelHeight; - const x = tile.column * this.tileWidth; - const y = tile.row * this.tileHeight; const w = tile.pixelWidth; const h = tile.pixelHeight; var imgData = this.context.createImageData(w, h); tile.data.forEach((val, i) => { imgData.data[i] = val; }) this.context.clearRect(x, y, w, h); // Clear. this.context.putImageData(imgData, x, y); // Draw. tile.isFilled = false; }); } 仅需修改 2 个源码中的变量,即可解决动态切换笔触的问题。 逻辑填坑结束,继续来完善交互界面,我们可以使用 range bar 等控件来实现交互界面调整笔触,然后基于鼠标事件来控制画笔行为,在鼠标 mousedown 时激活绘制,在 mousemove 时不断传入坐标给实例方法进行绘制处理,在 mouseup 时停止绘制,最终仅需少量代码 + 自己实现简单的交互界面即可低成本实现功能需求,且易于维护,后续如果需要新的处理算法比如亮度、对比度调整等,也只需集成相应的处理类即可,在控制面板通过切换功能模式来激活不同的类进行使用。 示例效果如下:

157
life.caoover 2 years

微前端实践和总结

微前端实践和总结 背景 因为团队内有一个很重要的应用,维护成本一直很高,开发体验极其不好,团队规划要在下半年完成该项目的微前端拆分,所以提前学习了微前端的概念与实现原理,总结出了这篇文章。 什么是微前端 微****前端一词于 2016 年底首次出现在 ThoughtWorks 技术雷达中。微前端理念就是将网站或 Web 应用程序视为由独立团队拥有的功能的组合。每个团队都有不同的业务领域或任务,一个团队可以从数据库到用户界面,端到端地开发其功能。它关心和专注于两个问题: 1、随着项目迭代应用越来越庞大,难以维护。 2、跨团队或跨部门协作开发项目导致效率低下的问题。 微前端出现的背景和意义 应用的架构设计 以一个面向产品销售的大型中台项目为例,售前、售中、售后、报表等等能力都是业务运营需要的,他们需要的是能完成业务闭环的系统。固然一种能力一个系统似乎更符合低耦合、高内聚的技术思维,但是并不符合用户体验的需要。 那么在微前端出现之前,面对这种需求,大多数的做法都是在同一个仓库中维护所有的业务能力,全部集成在一个 SPA(Single-page Application)中。少数的做法则是将大系统拆分成多个系统,在首页聚合所有子系统入口,即 MPA(Multi-page Application) 第一种做法: 优势: 统一的权限管理 代码复用十分便捷 更好的用户体验 劣势 代码权限管控问题 项目构建时间长 需求发布相互阻塞 代码 commit 混乱、分支混乱 技术体系要求统一 无法同时灰度多条产品功能 代码回滚相互影响 错误监控无法细粒度拆分 第二种做法: 优势: 更符合低耦合、高内聚的技术思维 构建时间长 可以使用独立的技术栈 仓库分支 commit 管理清晰 功能互不影响 劣势 用户体验割裂,不断的跳转 公共包基础库重复加载 产品权限无法进行统一收敛 第一种虽然提升了用户体验,但是可以看到日常开发极其痛苦:发布需求时被需求阻塞、无法局部灰度局部升级、项目遇到问题时回滚影响其他业务、无法快速引进新的技术体系提高生产力,项目的迭代和维护对于研发同学而言无疑是噩梦。而第二种做法虽然提升了开发体验,但却降低了用户体验。 所以在这个背景下,传统的开发架构已经无法应对这类大型项目的开发,微前端架构模式应运而生。 微前端架构 主流方案 现在社区主流的方案主要有以下几类: 拆分为基座应用、子应用,由基座应用负责加载子应用,代表方案有 qiankun 、 micro-app 、 garfish 去中心化方案,基于 module federation ,代表方案有 emp iframe 浏览器原生隔离 优劣对比 方案 优势 劣势 中心化方案 最主流的解决方案,相对成熟,接入代价小,遇到问题基本都有解决方案 公用包、公用组件、公用库相对困难 module federation 全新的代码复用方式,完善的公用包、公用组件、公用库复用方案 接入代价相对大,与构建强关联,老项目接入需要改造构建脚本。 iframe 简单粗暴 1. 性能差2. 应用间通信繁琐 核心实现原理 微前端的核心价值在于每个应用间技术栈无关、独立开发、独立部署、独立运行。那么我们要解决的的问题有以下几个: 应用的注册与渲染 基座应用和子应用代码运行在同一个环境,需要实现沙箱来实现应用间的隔离 样式的隔离 本文下面会讲解这些核心能力的实现原理。虽然笔者主要参考自 micro-app 的实现,但各个框架的这些能力的实现方式大同小异,可以当成通用知识学习。 应用的注册与渲染 在有了基座应用后,需要先注册好子应用的信息,然后才能对子应用的加载、渲染、卸载实现管理。主要需要以下几个信息: 应用名称,一个应用名称对应一个应用,不可重复 url,即应用的加载地址 以 micro-app 的实现为例,因为 micro-app 是基于 Web Components 实现的,所以应用的创建即组件的创建,所以首先,需要通过customElements创建一个自定义元素,并在 connectedCallback(元素被插入到DOM时执行)函数中执行初始化应用的操作,并在 disconnectedCallback(元素从DOM中删除时执行)函数中执行应用的卸载,所以我们还需要定义一个class 来管理应用的实例,并实现应用创建、加载与卸载的逻辑。 export class MyElement extends HTMLElement { // 声明需要监听的属性名,只有这些属性变化时才会触发attributeChangedCallback static get observedAttributes () { return ['name', 'url'] } constructor() { super(); } connectedCallback() { // 元素被插入到DOM时执行,此时去加载子应用的静态资源并渲染 console.log('micro-app is connected') // 创建微应用实例 const app = new CreateApp({ name: this.name, url: this.url, container: this, }) } disconnectedCallback () { // 元素从DOM中删除时执行,此时进行一些卸载操作 console.log('micro-app has disconnected') } attributeChangedCallback (attr, oldVal, newVal) { // 元素属性发生变化时执行,可以获取name、url等属性的值 console.log(`attribute ${attrName}: ${newVal}`) } } /** * 注册元素 * 注册后,就可以像普通元素一样使用micro-app,当micro-app元素被插入或删除DOM时即可触发相应的生命周期函数。 */ window.customElements.define('micro-app', MyElement) // 创建微应用 export default class CreateApp { constructor () { super() } status = 'created' // 组件状态,包括 created/loading/mount/unmount // 存放应用的静态资源 source = { links: new Map(), // link元素对应的静态资源 scripts: new Map(), // script元素对应的静态资源 } // 资源加载完时执行 onLoad () {} /** * 资源加载完成后进行渲染 */ mount () {} /** * 卸载应用 * 执行关闭沙箱,清空缓存等操作 */ unmount () {} } 在完成上面的定义后,不难看出,我们需要补充应用资源的加载与实现的逻辑。首先,需要去加载子应用的 html,并将 head 和 body 进行处理。 import { fetchSource } from './utils' export default function loadHtml (app) { fetchSource(app.url).then((html) => { html = html .replace(/<head[^>]*>[\s\S]*?<\/head>/i, (match) => { // 将head标签替换为micro-app-head,因为web页面只允许有一个head标签 return match .replace(/<head/i, '<micro-app-head') .replace(/<\/head>/i, '</micro-app-head>') }) .replace(/<body[^>]*>[\s\S]*?<\/body>/i, (match) => { // 将body标签替换为micro-app-body,防止与基座应用的body标签重复导致的问题。 return match .replace(/<body/i, '<micro-app-body') .replace(/<\/body>/i, '</micro-app-body>') }) // 将html字符串转化为DOM结构 const htmlDom = document.createElement('div') htmlDom.innerHTML = html console.log('html:', htmlDom) // 进一步提取和处理js、css等静态资源 extractSourceDom(htmlDom, app) }).catch((e) => { console.error('加载html出错', e) }) } 在获取完 html 之后,开始对 dom 进行遍历,处理 style , link , script 标签,获取子应用需要加载哪些远端资源。 /** * 递归处理每一个子元素 * @param parent 父元素 * @param app 应用实例 */ function extractSourceDom(parent, app) { const children = Array.from(parent.children) // 递归每一个子元素 children.length && children.forEach((child) => { extractSourceDom(child, app) }) for (const dom of children) { if (dom instanceof HTMLLinkElement) { // 提取css地址 const href = dom.getAttribute('href') if (dom.getAttribute('rel') === 'stylesheet' && href) { // 计入source缓存中 app.source.links.set(href, { code: '', // 代码内容 }) } // 删除原有元素 parent.removeChild(dom) } else if (dom instanceof HTMLScriptElement) { // 并提取js地址 const src = dom.getAttribute('src') if (src) { // 远程script app.source.scripts.set(src, { code: '', // 代码内容 isExternal: true, // 是否远程script }) } else if (dom.textContent) { // 内联script const nonceStr = Math.random().toString(36).substr(2, 15) app.source.scripts.set(nonceStr, { code: dom.textContent, // 代码内容 isExternal: false, // 是否远程script }) } parent.removeChild(dom) } else if (dom instanceof HTMLStyleElement) { // 进行样式隔离 } } } 然后开始请求资源并执行渲染,css 直接用 style 标签插入 dom 就好,script 我们需要在真正的 dom 节点挂载后依次执行。 /** * 获取link远程资源 * @param app 应用实例 * @param microAppHead micro-app-head * @param htmlDom html DOM结构 */ export function fetchLinksFromHtml (app, microAppHead, htmlDom) { const linkEntries = Array.from(app.source.links.entries()) // 通过fetch请求所有css资源 const fetchLinkPromise = [] for (const [url] of linkEntries) { fetchLinkPromise.push(fetchSource(url)) } Promise.all(fetchLinkPromise).then((res) => { for (let i = 0; i < res.length; i++) { const code = res[i] // 拿到css资源后放入style元素并插入到micro-app-head中 const link2Style = document.createElement('style') link2Style.textContent = code microAppHead.appendChild(link2Style) // 将代码放入缓存,再次渲染时可以从缓存中获取 linkEntries[i][1].code = code } // 处理完成后执行onLoad方法 app.onLoad(htmlDom) }).catch((e) => { console.error('加载css出错', e) }) } /** * 获取js远程资源 * @param app 应用实例 * @param htmlDom html DOM结构 */ export function fetchScriptsFromHtml (app, htmlDom) { const scriptEntries = Array.from(app.source.scripts.entries()) // 通过fetch请求所有js资源 const fetchScriptPromise = [] for (const [url, info] of scriptEntries) { // 如果是内联script,则不需要请求资源 fetchScriptPromise.push(info.code ? Promise.resolve(info.code) : fetchSource(url)) } Promise.all(fetchScriptPromise).then((res) => { for (let i = 0; i < res.length; i++) { const code = res[i] // 将代码放入缓存,再次渲染时可以从缓存中获取 scriptEntries[i][1].code = code } // 处理完成后执行onLoad方法 app.onLoad(htmlDom) }).catch((e) => { console.error('加载js出错', e) }) } // 创建微应用 export default class CreateApp { ... // 资源加载完时执行 onLoad (htmlDom) { this.loadCount = this.loadCount ? this.loadCount + 1 : 1 // 第二次执行且组件未卸载时执行渲染 if (this.loadCount === 2 && this.status !== 'unmount') { // 记录DOM结构用于后续操作 this.source.html = htmlDom // 执行mount方法 this.mount() } } /** * 资源加载完成后进行渲染 */ mount () { // 克隆DOM节点 const cloneHtml = this.source.html.cloneNode(true) // 创建一个fragment节点作为模版,这样不会产生冗余的元素 const fragment = document.createDocumentFragment() Array.from(cloneHtml.childNodes).forEach((node) => { fragment.appendChild(node) }) // 将格式化后的DOM结构插入到容器中 this.container.appendChild(fragment) // 执行js this.source.scripts.forEach((info) => { (0, eval)(info.code) }) // 标记应用为已渲染 this.status = 'mounted' } } 可以看到,所有的静态资源加载都是通过基座应用去请求的,所以在大多微前端框架的使用中,子应用必须支持跨域请求。可能会有同学会有疑惑为什么要做这么多工作来实现 js 与 css 的加载的劫持,直接获取地址插入 dom 让浏览器自行处理不就好了么?我们往后看,就会发现这其实这都是为后面沙箱的劫持与样式隔离的实现做铺垫。 沙箱的实现 常见沙箱实现主要有两种: 快照实现,即在应用激活时保存,卸载时恢复 这种实现其实原理很简单,下面我们来看 qiankun 的 snapshotSandbox 简单的实现 const iter = (window, callback) => { for (const prop in window) { if(window.hasOwnProperty(prop)) { callback(prop); } } } class SnapshotSandbox { constructor() { this.proxy = window; this.modifyPropsMap = {}; } // 激活沙箱 active() { // 缓存active状态的window this.windowSnapshot = {}; iter(window, (prop) => { this.windowSnapshot[prop] = window[prop]; }); Object.keys(this.modifyPropsMap).forEach(p => { window[p] = this.modifyPropsMap[p]; }) } // 退出沙箱 inactive(){ iter(window, (prop) => { if(this.windowSnapshot[prop] !== window[prop]) { // 记录变更 this.modifyPropsMap[prop] = window[prop]; // 还原window window[prop] = this.windowSnapshot[prop]; } }) } } 这种实现的优劣之处是显而易见的,优点在于实现简单,缺点在于性能差(每次激活与卸载都要遍历window 的所有属性)、无法实现同时多个实例。 proxy 实现,基于es6的属性劫持实现方案。 proxy 的使用无需多言,下面我们直接看简单实现 export default class ProxySandbox { active = false // 沙箱是否在运行 constructor() { const originWindow = window const fakeWindowMap = new Map() const proxy = new Proxy(fakeWindow, { set(target, p, value) { fakeWindowMap.set(p,value) return true }, get(target, p) { if(this.active){ return fakeWindowMap.get(p) || originWindow[p] }else{ return originWindow[p] } }, }) this.proxy = proxy } } 原理就是代理原生的 window,获取全局属性时从当前沙箱实例保存的 map 中获取。这样实现下,同时运行多个子应用,每个子应用就能在自己单独的沙箱环境下运行了。这只是简单演示实现原理,实际使用中还需要考虑全局事件监听等等诸多问题,对进一步实现有兴趣的可以去看各大框架的源码实现。 那写完了沙箱的实现,在微前端框架中何时且如何使用它呢。上一节讲的执行加载 js 的过程,就可以把我们的沙箱劫持逻辑补充进去了。 import loadHtml from './source' + import Sandbox from './sandbox' export default class CreateApp { constructor ({ name, url, container }) { ... + this.sandbox = new Sandbox(name) } ... mount () { ... + this.sandbox.start() // 执行js this.source.scripts.forEach((info) => { (0, eval)(info.code) }) } /** * 卸载应用 * @param destory 是否完全销毁,删除缓存资源 */ unmount (destory) { ... + this.sandbox.stop() // destory为true,则删除应用 if (destory) { appInstanceMap.delete(this.name) } } } 我们需要将之前加载的 js,处理成下面的格式,来实现 window 的劫持。 (function(window, self) { with(window) { 子应用的js代码 } }).call(代理对象, 代理对象, 代理对象) 通过字符串拼接就可以实现 export default class ProxySandbox { ... // 修改js作用域 bindScope (code) { window. proxy = this. proxy return `;(function(window, self){with(window){;${code}\n}}).call(window.proxyWindow, window.proxyWindow, window.proxyWindow);` } } 这样,我们就实现了每个应用间 js 执行的环境隔离了。不难看出,这种实现在子应用使用浏览器中的原生 es module (比如 vite 的开发模式)的时候,就无能为力了。所以现在各大微前端框架在接入 vite 应用时,通常会要求将沙箱关闭,目前还没有看到社区中对这个问题有好的解决方案。 样式隔离 处理完了执行环境的隔离,那如何实现样式的隔离呢?有些同学可能马上就想到了 cssModule ,当然cssModule 可以解决样式隔离的问题,但是对于现有没有使用 cssModule 的应用来说就比较尴尬了,所以现有的框架大多都支持无感知实现样式的隔离。 以 micro-app 为例子,打开官方示例,用 chrome devtools 查看样式就可以发现,它的样式隔离是通过给子应用中的css属性选择器都加上了micro-app[name=xxx]的前缀来实现的。 具体的原理是什么呢,那就要提到一个相对比较冷门的api CSSRule 了,通过这个 api ,我们能获取到每个 style 内的所有 css 规则集合并进行处理。值得注意的是,实际使用中, CSSRule 实际也受浏览器同源策略的影响,而现代前端开发中,css 等静态资源通常会上传到不同源的 cdn 上,所以很少会有实际用到它的地方,但我们在渲染一节中,实现了 css 加载的劫持,并将请求到的 css 资源转换成 style 标签插入dom中,就可以不受同源策略的影响了~ 下面直接看代码,将我们获取的css放到一个新建的style标签添加到 dom 后,将其设置为 disabled (防止处理过程中对样式造成影响),就可以通过 styleElement.sheet.cssRules 获取到这个 style 标签内所有的 css 规则,然后对规则的 selectorText(即选择器)加上对应子应用的前缀就可以实现我们想要的效果了。 /** * 依次处理每个cssRule * @param rules cssRule * @param prefix 前缀 */ function scopedRule (rules, prefix) { let result = '' // 遍历rules,处理每一条规则 for (const rule of rules) { switch (rule.type) { case 1: // STYLE_RULE result += scopedStyleRule(rule, prefix) break case 4: // MEDIA_RULE result += scopedPackRule(rule, prefix, 'media') break case 12: // SUPPORTS_RULE result += scopedPackRule(rule, prefix, 'supports') break default: result += rule.cssText break } } return result } /** * 修改CSS规则,添加前缀 * @param {CSSRule} rule css规则 * @param {string} prefix 前缀 */ function scopedStyleRule (rule, prefix) { // 获取CSS规则对象的选择和内容 const { selectorText, cssText } = rule // 处理顶层选择器,如 body,html 都转换为 micro-app[name=xxx] if (/^((html[\s>~,]+body)|(html|body|:root))$/.test(selectorText)) { return cssText.replace(/^((html[\s>~,]+body)|(html|body|:root))/, prefix) } else if (selectorText === '*') { // 选择器 * 替换为 micro-app[name=xxx] * return cssText.replace('*', `${prefix} *`) } const builtInRootSelectorRE = /(^|\s+)((html[\s>~]+body)|(html|body|:root))(?=[\s>~]+|$)/ // 匹配查询选择器 return cssText.replace(/^[\s\S]+{/, (selectors) => { return selectors.replace(/(^|,)([^,]+)/g, (all, $1, $2) => { // 如果含有顶层选择器,需要单独处理 if (builtInRootSelectorRE.test($2)) { // body[name=xx]|body.xx|body#xx 等都不需要转换 return all.replace(builtInRootSelectorRE, prefix) } // 在选择器前加上前缀 return `${$1} ${prefix} ${$2.replace(/^\s*/, '')}` }) }) } 然后我们将这段处理加到 css 的挂载过程中就可以实现样式隔离了。但是需要注意的是,还需要对 style 的插入进行处理,通常都是通过劫持全局的 createElement 等创建元素的方法或者使用MutationObserver api 监听 dom 的改变来实现。 总结 本文主要讲述我在学习微前端过程中的一些总结,并结合实例探讨了微前端框架几个核心能力的实现,碍于篇幅原因,很多细节没有提到。如果文章有错误或者不准确的地方,欢迎大家指正。 引用 https://juejin.cn/post/7016911648656982024 https://github.com/bailicangdu/simple-micro-app https://juejin.cn/post/7004661323124441102

250
life.caoover 4 years

关于UserAgent

关于UserAgent 前言 最近发现我们项目中对于 UserAgent 的识别判断方式各不一样,可能有数十种写法,而且涉及的前端项目众多。于是笔者打算对 UserAgent 进行一个总结。 关于 UserAgent 定义 在 W3C 上找到一个对 UserAgent 的定义 A user agent is any software that retrieves, renders and facilitates end user interaction with Web content, or whose user interface is implemented using Web technologies. 原文翻译的话,UserAgent 是一个“软件/载体”,它获取内容并呈现,同时帮助终端用户与 Web 内容进行交互,或者这个“软件/载体”本身的用户交互界面就是以 Web 技术来实现。 英文字面翻译难免会有点生硬,没关系,因为在如今 UserAgent 更多指用户访问 Web 所用的设备特征。UserAgent 就是一个特征字符串,在 MDN 文档中可以看到它通常有如下的格式: Mozilla/ () () Mozilla 是一个通用标记符号,用来表示与 Mozilla 兼容,这几乎是现代浏览器的标配。 [system information]:系统与浏览器信息,如:Macintosh; Intel Mac OS X 10_15_7。 [platform]:用来说明浏览器所运行的原生系统平台(例如 Windows、Mac、Linux 或 Android),以及是否运行在手机上。 [platform details]:平台的其他补充信息,但各个浏览器为了兼容,这个字段没有实际意义,如:KHTML, like Gecko。 [extensions]:扩展字段,用于描述浏览器信息以及浏览器追加的自定义信息,如:Chrome/103.0.0.0 Safari/537.36。 APP 定制的 UserAgent 由于通常的 UserAgent 更多和系统平台相关,一些 APP 厂商也会定制 Webview 或在 UserAgent 中加入定制的一部分字符串。比如微信内打开 Webview 时,UserAgent 中会带有 MicroMessenger 或 Wechat 这样的字符串,而在小程序中打开 Webview 时,UserAgent 中会带有 miniProgram 这样的字符串, 如何解析 UserAgent APP 在系统 UserAgent 末尾追加了一段定制的部分,内容包括:APP 版本号,系统,渠道名称,设备型号,设备信息等。 因此,我们解析 UserAgent 的需求主要有以下三部分: 系统信息:操作系统,操作系统版本 APP 信息:APP 包名,版本号,渠道名称 设备信息:设备型号,可选的设备信息 了解下开源方案 我们可以在 github 上搜到很多关于 UserAgent 的仓库,Stars 较多的有这两个。 faisalman/ua-parser-js 此项目 Owner 维护十年了,值得称赞 fex-team/ua-device 百度 FEX 团队出品,更针对国内的浏览器和手机厂商 从这两个项目上,我们可以意识到这两点: UserAgent 的实际情况非常复杂,解析是讲成功率的,很难有 100% 正确的情况 解析 UserAgent 需要与时俱进,因为外部环境一直在变化

152
life.caoover 4 years

浅谈AST

浅谈AST 最近在项目中遇到了需要大规模重构的代码的场景,以此为契机了解到了以抽象语法树操作为核心的jscodeshift 项目,感觉可以做个分享,所以写下了这篇文章。 抽象语法树 ( Abstract Syntax Tree ) 概念 定义 : 在计算机科学中,抽象语法树(以下我们用 AST 简写)是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是「抽象」的,是因为这里的语法并不会表示出真实语法中出现的每个细节。 AST 生成过程 1.词法分析( Tokenization ) 2.语法分析( Syntactic Analysis ) JavaScript 中的抽象语法树 目前 JavaScript 编译器遵循的通用规范 —— ESTree (社区规范),不同的编译工具都是基于此结构进行了相应的拓展。 常见的解析工具: acron ( webpack ) babel-parser ( babel ) espree ( eslint ) 可视化工具 : https://astexplorer.net/ http://nhiro.org/learn_language/AST-Visualization-on-browser.html 应用 说了那么多,那学习 AST 有什么用呢,下面我们来看一个实际应用~ 代码重构替换 场景 小明发现项目里的大量 await 没有使用 try... catch{} 语句包裹,意味着如果await的promise 状态最终为 rejected 的话,会有很大的风险 async function getTest() { const b = a + await getApi(); } async function getApiTest() { await getApiTest(); } async function getQuestionsTest() { const c = b + await new Api().getQuestionsTest(); } async function getNormalTest() { const c = await getTest; } async function reduce(array, reducer, accumulator) { for (let i = 0; i < array.length; i++) { accumulator = await reducer(accumulator, array[i], i, array); } return accumulator; } async function testTest() { await every([2, 3], async v => (await fetch(v)).ok); } const a = async function(req, res) { const tasks = await repository.get(); } async function testReturnAwaitTest() { return await getTest(); } const CompaniesController = { verifySubDomain: async request => { const company = await Company.findOne({ where: { id: request.params.id } }); } } async function expressionStatement() { a + await getApi(); } class Abc extends React.Component { async componentDidMount() { const b = await getTest(); } } getTest(async () => { await getTest(); }) 但是手动替换需要的时间过多,正则替换又无法覆盖全部场景,这个时候 jscodeshift 就派上用场了。 jscodeshift codemod 是一个诞生于 Facebook 内部的概念,可以理解为 「code modification」 的缩写。如官方介绍所述,codemod 针对的场景是规模较大的代码库中的重构工作。当某个在代码中被频繁使用的接口发生了无法向前兼容的重大变化,codemod 提供了快速且可靠的、半自动的工具来对代码库中所有相关代码进行重构,以帮助开发者对代码进行快速迭代。 jscodeshift 是一个由 facebook(现在的 meta ) 开源的重构代码的工具集,通过 jscodeshift 编写 codemod , 然后对指定文件运行就可以批量重构代码,大大减少了体力劳动,并可复用。 npm install -g jscodeshift 为什么不用 babel babel 的目的是对代码向下兼容的,会进行代码转换,而且即使不做任何修改,输出的代码和原本的也有区别,比如空格,空行,注释位置会变化,所以不使用,而 jscodeshift 会保留没发生修改的代码的原有格式 。 jscodeshift 的基本使用 module.exports = function ( fileInfo, // 当前处理文件的相关信息,包括文件路径与内容 api, // jscodeshift 提供的接口 options // 通过 jscodeshift CLI 传入的参数 ) { const { source, path } = fileInfo; const { jscodeshift: j } = api; const root = j(source); // 解析代码获得 AST // 在这里编写操作 AST 的代码 return root.toSource(); // 将 AST 转换为代码字符串后返回 } https://astexplorer.net/#/gist/e552b7dcd05915d91deab8b822391822/a60f9fb501fdb1911f147aca9396f097b17ce054 实现我们的需求 找到需处理的节点 首先我们需要梳理找出哪几种 AST 节点可能会包含 await 语句,总共有以下三种,找到后我们可以在 AST explorer 中找到对应的 AST 节点类型。 // 变量声明 let a = await getApiTest(); // await表达式 await getApiTest(); // return 语句中 async function getNormalTest() { return await getTest; } 我们可以写出以下代码 ( ps : 因为一句变量声明语句可以声明多个变量,所以对应的 AST 节点定义为VariableDeclaration(一句变量声明语句)=> VariableDeclarator(多个变量声明),所以我们需要使用其父节点作为处理函数的入参操作 )。 export const parser = "babel"; // Press ctrl+space for code completion export default function transformer(file, api, options) { const j = api.jscodeshift; const root = j(file.source); // 替换函数 function replaceWithTryCatch(path, type) { j(path).replaceWith( // try catch AST ); } root.find(j.VariableDeclarator).forEach((path) => { if (path.node.init) { replaceWithTryCatch(path.parent, path.parent.node); } }); root.find(j.ExpressionStatement).forEach((path) => { if (path.node.expression.type !== "CallExpression") { replaceWithTryCatch(path, path.node); } }); root.find(j.ReturnStatement).forEach((path) => { replaceWithTryCatch(path, path.node); }); return root.toSource(); } 筛选需要处理的节点 不包含 await 语句 以及 已在 try...catch{} 语句块中的 await语句,是不需要处理的,所以我们需要过滤掉不符合要求的 AST 节点 // 判断是否已在 try...catch{} 语句块中 function isAlreadyInsideTryBlock(path) { return j(path).closest(j.TryStatement).length; } // 判断树中是否包含 await 语句 function doesNotHaveAwaitStatement(path) { return !j(path).find(j.AwaitExpression).length; } // 替换函数 function replaceWithTryCatch(path, type) { if (isAlreadyInsideTryBlock(path) || doesNotHaveAwaitStatement(path)) return; j(path).replaceWith( //try catch AST ); } 编写 AST 替换代码 // 构造 catch 内的代码对应的 AST 语法树 function getCatchBlockExpression() { return [ j.expressionStatement( j.callExpression( j.memberExpression(j.identifier("console"), j.identifier("log")), [j.identifier("e")] ) ), ]; } // 替换函数 function replaceWithTryCatch(path, type) { if (isAlreadyInsideTryBlock(path) || doesNotHaveAwaitStatement(path)) return; j(path).replaceWith( j.tryStatement( j.blockStatement([type]), j.catchClause( j.identifier("e"), null, j.blockStatement(getCatchBlockExpression()) ) ) ); } 最后我们得到下面的完整代码 https://astexplorer.net/#/gist/8d9941609565825e84a66f9a5740a8d1/9cbc2c92458747b798cb7d3f4f23f7f3cd36cebf export const parser = "babel"; export default function transformer(file, api, options) { const j = api.jscodeshift; const root = j(file.source); // 构造 catch 内的代码对应的 AST 语法树 function getCatchBlockExpression() { return [ j.expressionStatement( j.callExpression( j.memberExpression(j.identifier("console"), j.identifier("log")), [j.identifier("e")] ) ), ]; } // 判断是否已在 try...catch{} 语句块中 function isAlreadyInsideTryBlock(path) { return j(path).closest(j.TryStatement).length; } // 判断树中是否包含 await语句 function doesNotHaveAwaitStatement(path) { return !j(path).find(j.AwaitExpression).length; } // 替换函数 function replaceWithTryCatch(path, type) { if (isAlreadyInsideTryBlock(path) || doesNotHaveAwaitStatement(path)) return; j(path).replaceWith( j.tryStatement( j.blockStatement([type]), j.catchClause( j.identifier("e"), null, j.blockStatement(getCatchBlockExpression()) ) ) ); } // 因为这几种情况下如果有 await 应都包裹在函数内部(不考虑顶层 await 的情况),所以处理变量声明节点时跳过,否则会导致 try catch 包裹在 async 函数外部,转换无效 const notTypes = [ "ArrowFunctionExpression", "FunctionExpression", "ObjectExpression", ]; root.find(j.VariableDeclarator).forEach((path) => { if (path.node.init && notTypes.indexOf(path.node.init.type) < 0) { replaceWithTryCatch(path.parent, path.parent.node); } }); root.find(j.ExpressionStatement).forEach((path) => { if (path.node.expression.type !== "CallExpression") { replaceWithTryCatch(path, path.node); } }); root.find(j.ReturnStatement).forEach((path) => { replaceWithTryCatch(path, path.node); }); return root.toSource(); } jscodeshift 常用的 API 以及相关的参考文档和源码 AST 的查找与筛选 :find() 、filter() Collection 访问 :get() 、at()(两者区别在于前者返回 NodePath ,后者返回 Collection) 节点的插入与修改 :replaceWith()、insertBefore()、insertAfter() 踩坑 // 不支持使用内置的 AST 操作生成,比如数组解构语句 let [a,b] = [1,2] // 可以直接传入字符串 j(path).replaceWith('let [a,b] = [1,2]') 参考文档 Collection 常用 API:api nodeapi AST 节点构建参数 : ast jscodeshift CLI 参数 : cli 可以学习借鉴的 codemodGitHub - rajasegar/awesome-codemods: Awesome list of codemods for various languages, libraries and f GitHub - sejoker/awesome-jscodeshift: A curated list of jscodeshift packages and resources. 总结 实际应用中抽象语法树其实还有除了代码重构外更多的作用,比如 babel、webpack 等底层工具都用到了抽象语法树,接下来还可以应用到自定义语法等等场景中,抛砖引玉,期待大家评论区讨论~

14
life.caoover 4 years

Proxy配合使用Reflect

Proxy配合使用Reflect 在深入理解 Proxy 与 Reflect 后,项目中用到 Proxy 会事半功倍。 咱们从问题出发,一起探讨 Proxy 与 Reflect 的关系 一、只用 Proxy 遇到的问题 const obj = { name: "obj", get value() { return this.name; }, }; const handler = { get(target, key, receiver) { // receiver === proxy: true console.log("receiver === proxy:", receiver === proxy); return target[key]; }, }; const proxy = new Proxy(obj, handler); console.log(proxy.name); // obj const obj = { name: "obj", get value() { return this.name; }, }; const handler = { get(target, key, receiver) { // receiver === proxy: false console.log("receiver === proxy:", receiver === proxy); // receiver === child: true console.log("receiver === child:", receiver === child); return target[key]; }, }; const proxy = new Proxy(obj, handler); const child = { name: "child", }; Object.setPrototypeOf(child, proxy); console.log(child.value); // obj obj通过访问器 get value 访问 this.name。然后用proxy代理obj,含有name属性的child继承proxy。在访问 child.value理想中是获取child内属性name值,但是此时返回的是obj内的name值。那我们该怎么实现呢? 如果大家对 Proxy 和 Reflect 比较熟悉的话,只需要修改一下 get 捕捉器即可 const handler = { get(target, key, receiver) { return Reflect.get(target, key, receiver); }, }; 咱们带着这几个疑问一起继续探讨一下: child.value 是如何访问到get 捕捉器的 receiver 是什么 Reflect.get 是怎样修改了 get value 里面的 this 指针的 二、child.value 做了些什么 咱们可以从 ECMAScript 语法表达式入手,查看了 ECMAScript 2023 的 13.3.2.1 Runtime Semantics: Evaluation MemberExpression : MemberExpression . IdentifierName a.令 baseReference 为求值 MemberExpression 的结果; b.令 baseValue 为? GetValue(baseReference); c.如果MemberExpression 匹配的代码为严格模式代码,令 strict 为 true;否则令 strict 为 false d.返回? EvaluatePropertyAccessWithIdentifierKey(baseValue, IdentifierName, strict)。 看了这个,对于 child.value 的操作更加清晰了。先 GetValue(baseReference) 然后再返回 ? EvaluatePropertyAccessWithIdentifierKey(baseValue, IdentifierName, strict)。 接下去咱们分别探讨 这两个的算法 GetValue(baseReference) a.ReturnIfAbrupt(V) b.如果 V 不是引用类型 返回 V。 c.如果 IsUnresolvableReference(V)为true,抛出 ReferenceError 异常; d.如果 IsPropertyReference(V)为 true,则 a. 令 baseObj 为 <u>ToObject</u>``(V.[[Base]]). b.如果 <u>IsPrivateReference</u>``(V) 为 true 返回 <u>PrivateGet</u>``(baseObj, V.[[ReferencedName]]). c. 返回 baseObj.[[Get]](V.[[ReferencedName]], GetThisValue(V)). 否则 a. 令 base 为 V.[[Base]] b. 断言 base 为环境记录; c. 返回? base.GetBindingValue(GetReferencedName(V), IsStrictReference(V))。 在我们的案例 child.value 是个属性引用,直接进入 5️⃣ 中,又因为不是私有类元素,因此直接进入 5️⃣ c 中。proxy.name 实际是调用 [[Get]],这里有两个入参,第一个是 属性 key,第二个是 GetThisValue(V))。继续看看[[Get]] 和 GetThisValue(V) 算法 GetThisValue(V) a.断言:IsPropertyReference(V)为 true. b.如果IsSuperReference(V)为 true ,返回 引用 V 的 thisValue 值;否则返回 V.[[Base]] 上面可以看出 [[Get]] 的第二个参数 GetThisValue(V)) 是 V.[[Base]],因为 案例中 child.value 的 child不是一个超类。这里的 V.[[Base]] 是原始引用的基础值 this V。 [[Get]] 在上面的案例中 p 是属性value,Receiver是 child,[[GET]] 执行了 OrdinaryGet(O, P, Receiver) OrdinaryGet(O, P, Receiver) 这里的 O 就是 child, P 就是 value,Receiver 就是 child a.令 desc 为? O.[[GetOwnProperty]](P); b.如果 desc 为 undefined,则 c.令 parent 为? O.[[GetPrototypeOf]](); d. 如果 parent 为 null,返回 undefined; e.返回? parent.[[Get]](P, Receiver); 如果 IsDataDescriptor(desc)为 true,返回 desc.[[Value]]; 断言:IsAccessorDescriptor(desc)为 true; 令 getter 为 desc.[[Get]]; 如果 getter 为 undefined,返回 undefined; 返回? Call(getter, Receiver)。 child.value 自身没有这个属性,因此直接走向 2️⃣ c,直接调用 proxy 的 [[GET]],并将 属性 value 和 receiver child传给了 proxy 的捕捉器 get。通过这波解释 知道了 child.value 的时候,proxy 的 get 第三个参数 receiver 是 child了。 咱们再继续 分析 Proxy 的 [[GET]] 6. Proxy 的 [[Get]] ( P, Receiver ) 核心步骤是1-7、10 让 handler 成为 O.[[ProxyHandler]] --- O是代理对象 如果 handler 是 null,抛出语法异常 断言 handler 类型是 Object 让 target 成为 O.[[ProxyTarget]] 让 trap 成为 GetMethod(handler, "get") --- (查阅GetMethod,从handler内获取get属性值) 如果 trap 是 undefined,返回 target.[[Get]](P, Receiver) 否则 返回 Call(trap, handler, « target, P, Receiver ») 直接执行 handler 的 get 捕捉器,并将 child作为捕捉器的第三个参数 Receiver 传入。 EvaluatePropertyAccessWithIdentifierKey ( baseValue, identifierName, strict ) 最后回过头看看 EvaluatePropertyAccessWithIdentifierKey 算法 令 propertyNameString 为 identifierName 的 StringValue; 返回一个引用,其基础值为 baseValue、引用名为 propertyNameString、严格引用标志为 strict。 经过这一波操作时候,其实 EvaluatePropertyAccessWithIdentifierKey 是构造一个引用,baseValue为基础值,propertyNameString 作为属性名称,strict 作为严格引用标志。 最终是将 GetValue 获取的值作为 child.value 的结果。 经过这波探讨之后,绘制了下面的流程图,可以更清晰的了解 child.value 做了什么 经过上面的分析 Receiver 是调用对象,如果child.value,Receiver 是通过 GetThisValue(V) 获取,并通过 OrdinaryGet 将 Receiver传递给 [[GET]],因此在调用算法中,Receiver 是传递下去,不变更的。如果 proxy.value,Receiver是 proxy。 三、Reflect 如何修改get的 this 指向 Reflect.get target:obj,prpertyKey:value,receiver:child 如果 Type(target) 不是 Object,就会抛出语法错误 令 key 成为 ?ToPropertyKey(propertyKey)。 如果 receiver不存在,将 receiver 赋值为 target 返回 ? target[[Get]](key, receiver). 在结合上面 [[GET]] 和 OrdinaryGet 的 7️⃣ 返回? Call(getter, Receiver) 因此 Reflect.get(target, key, receiver); 就相当于 执行 target 里面的 getter,修改了 getter内部的指针 get.call(receiver). 四、总结 obj.name 执行算法 实则调用内部函数 [[GET]],并且 receiver 是 obj Proxy 中 receiver 是 Proxy 或者继承 Proxy 的对象 Reflect.get(target, key, receiver) 就相当与 执行了 get.call(receiver).

9
life.caoover 4 years

关于如何提高效能的陷阱

关于如何提高效能的陷阱 1.写代码 这是一个自然语言特性导致的思维误区。「写代码」是什么意思?「写」是个谓语动词,「代码」是个名词作为宾语,这个短语的组成结构很完整,正常情况下都会按照字面意思去理解。但实际在做「写代码」这件事的时候,真的只是「写」吗?如果是,那说明你快要失业了,因为「写」这种简单的动作太容易被自动化替代了。在「写代码」的过程中,更多的时间是花在「思考代码怎么写」上的。要在脑子里分解需求的逻辑,经过适当抽象与权衡,最后再组织成代码。这个思考的过程就是信息的提炼。 「写代码」这个看似简单的动作,主要可以分为「思考」和「写」两个部分。「写」的部分很容易优化,使用一个框架就能让你少写代码,选择一个好的 IDE 也能让你少写代码,甚至从曾经看过的代码 copy&paste 也能让你少写代码。 比起「写」的部分,「思考」的部分优化起来就困难多了,因为这涉及到信息的提炼。比如产品需求是做 20 个页面,如果接到需求就无脑写代码,每个页面都单独做,那可能就会有大量重复无效的工作量。所以要分析需求,考虑好怎么做分层设计,怎么抽象逻辑,甚至结合对未来业务需求变化的预判做适当的预留,最后才是写代码。然而 信息量是相对于需求恒定 的,也就是说,需求的复杂度对应的信息量是有下限(柯氏复杂性)的,对于同一个需求,无论如何提炼,无论用什么语言来写,所需的代码都不可能低于这个下限。 关于「写代码」的效能优化可以总结为: 「写」的优化很容易,以至于已经被挖掘到没什么优化空间了; 「思考」的部分很难优化,对人的能力要求高,而且会遇到上限; 2.不写代码 最近一两年「零代码」、「低代码」这些概念比较火。为什么能够做到零代码或低代码呢?这不是违反自然规律吗?你可能是被这些标题党忽悠了。我有一种让买彩票的中奖概率翻倍的办法,想知道吗?呵呵,就是买两张彩票。如何做到零代码呢?呵呵,不接需求不就行了吗? 市面上的此类产品主要分两类。一类是针对垂直领域,比如报表搭建系统、表单搭建系统。这类平台只能做某种特定垂直领域的事,而且功能是受限的。一些搭建系统把自己称为是「低代码」或「零代码」平台,那我的需求是做一个点餐送餐系统,我的原型图和视觉稿都准备好了,你能做出来吗?所以这里平台之所以能做到「零代码」或「低代码」,主要就是因为把需求范围给压缩到某个垂直领域,其本质就是「不接需求」,剩余的定制逻辑从「写代码」变成「可视化操作」,就和把「写代码」变成「写配置」一样,偷换了「写」的形式,工作量并没有减少。这类垂直领域的效能产品上个世纪就有了,并不是什么新鲜玩意儿。而且这类产品确实有他们的价值,对组织效能的提升非常有效。特别要注意的是,这里说的是组织效能而不是研发效能。 市面上此类产品的另一种形态是想做一个可视化的 IDE。人类一直都没有停止对可视化 IDE 的尝试,我最开始接触 Web 的时候用过 Frontpage 和 Dreamweaver,他们都是在尝试通过可视化的交互来替代写代码。但是从历史看来,此类产品越来越不受待见,为什么?因为技术也是一直在发展的。早期 Web 1.0 时代的时候,Frontpage 这样的工具确实可以满足市场需求,因为网页只是单纯呈现信息的,页面板式只要不比报纸差就行。放到如今这个时代可还行?可视化虽然在复杂度较低的场景下有优势,一旦复杂度超过视觉表达可承载的极限,研发成本曲线就会变得非常陡峭。 这两种产品形态,前者侧重学习成本,后者侧重自由度,而且两者是不可调和的,自由度越高,学习成本就越高。 3.没有银弹 不要幻想着有一个完美的解决方案能够无代价地把写代码的效能提升上来,你能想到的事我们的前辈们都已经做过了,你的自我感觉良好可能只是因为你读书少。这个世界从来不缺乏大忽悠,网络上看到的一些文章和方案往往是片面的,他们很善于把拙劣的一面藏起来,只让你看到好的一面,这样才有狂热的粉丝追捧,才能从投资人手里弄到钱。 现如今,我们不是处于一个「择优」的阶段,而是处于「权衡」的阶段。在你所有的备选方案中就没有哪个方案是最优的,每个方案都有自己的优劣,那些干啥啥不行的方案早就被历史淘汰了,根本不会出现在你的备选方案列表中。 4.怎么破? 我们的目的是 提升组织效能,不要局限在「写代码」上。「写代码」这件事历史太悠久了,能提升的点,别人早就尝试过了。而且「写代码」只是研发过程中的一个小点,写代码之外还有评审、联调、对接、提测等,这一大堆流程的可优化空间获取比「写代码」要大得多。 不要执着于 研发效能,垂直领域的效能产品可以从研发效能之外更有效地提升 组织效能,这也是一个非常有意义的方向。 转自一位大佬的文章,找不到链接了,抱歉。

35
life.caoover 4 years

有了vite,umi还香吗?

有了vite,umi还香吗? 1.前言 曾几何时,我们一边享受 umi 强大生态带来的高效便捷的开发体验、一边叹息着它在本地开发时过久的编译耗时。 “如何在 umi 生态高效便捷和 webpack 编译耗时之间权衡?”,我们将目光投向了 Vite 。关于 Vite 的更多详细信息,可以点击官网链接查看。 2.Vite 出场 追本溯源,我们想要解决的是什么问题?这个在 Van 项目组很明确:(迁移前)本地开发编译耗时动辄几分钟,已经久到无法忍了。 2.1 Umi 为什么慢 除了 umi 自身提供的对运行时配置文件的监听变更等内置逻辑,基于 webpack 的 umi 当冷启动开发服务器时,基于打包器的方式启动必须优先抓取并构建你的整个应用,然后才能提供服务。因此随着应用体积的增长,需要处理的代码量也呈指数级增长。 2.2 Vite 怎么做的 通过在一开始将应用中的模块区分为 依赖 和 源码 两类改进开发服务器: 依赖 几乎是不变的 js 代码; 源码 也不需要在一开始的时候同时全部被加载; 通过原生 ESM 的方式提供源码(未提供 ESM 的依赖会在预构建阶段准备好),Vite 只需要在浏览器请求源码时进行转换并按需提供源码。 在此基础之上,Vite 还天生支持转译 ts 文件、CSS 预处理器、引用静态资源、具名导入JSON等功能,如果你需要快速搭建一个项目,Vite 可以作为优先选项之一,更多功能进入官网查看。 2.3 Vite 真的完美无缺吗 答案是否定的:Vite 的按需提供机制实际上让浏览器承载了打包程序的部分工作:浏览器请求源码文件,Vite进行解析、编译源码、转换生成并响应。 这种实现的问题在于当我们的源码按照功能模块划分成一个个的小文件,浏览器会对每个文件发起请求,请求数量过大会触发浏览器的同域最大请求并发数限制,不仅如此,对应到 Vite 的每次编译转换都需要时间。所以这会导致相比于 webpack,第一次访问 Vite 开发服务器时或触发了 hot-reload 时,浏览器会有明显的加载延迟。 对于上述情况,Vite 也内置了相应的缓存优化措施,对于依赖的代码会在响应头中设置强缓存相关字段,而对于源码的代码会设置协商缓存相关字段。 Cache-Control for dependency chunks ETag for source code 3.迁移工作 因为是构建工具的替换,所以除了构建工具的代码适配,业务代码基本上无需变更。迁移工作整体上做的就是两点: 把 umi 基于 webpack 的构建配置 .umirc.ts 由 Vite 承接; 替换 umi 的运行时配置 app.tsx4.收获 以下基于 Vite 和 Umi 在本地开发时安装依赖、执行编译等耗时上的比较: 4.1 本地开发 Vite Umi 安装依赖 yarn install ~30s ~100s 打包构建 yarn build ~70s ~90s 第一次编译 yarn start ~25s ~85s 第二次编译 yarn start / ~85s yarn install && yarn start ~50s ~240s 4.2 集成部署 Vite Umi 集成部署耗时总计 ~90s ~140s

14
life.caoalmost 5 years

从实践中总结的Git 操作

从实践中总结的Git 操作 一、背景介绍 近期在开发过程中因为 Git 操作不当以及使用不熟,带来了不少团队协作问题,比如:分支被覆盖、提交日志杂乱、分支创建很随意,同时在上线日当天需求被推迟上线,前端又需要对代码进行回滚以及 rebase 操作等系列问题。针对以上内部遇到的 Git 问题梳理了相关操作规范和 Git 使用指南。 二、Git 介绍 Git 是一个分布式版本控制工具,它可以非常轻松帮你管理任何时间任何人做的任何提交。 知识虽旧,历久弥坚。本文总结了在工作中是如何玩弄 Git 的,希望这篇总结对部分 Git 操作不熟的同学有所帮助。 概念理解 使用 Git 之前先了解 Git 仓库运转流程,共分为 4 个概念:本地空间、暂存区、本地仓库和远程仓库。参照下图: 本地空间:存放的本地代码 暂存区:存放修改后的文件 本地仓库:存放提交修改后的文件 远程仓库:存放远程推送代码 是不是有点像 Vuex : Action -> Mutation -> State 三、Git 工作使用 安装配置 下载 Git 软件 全局配置邮箱和账号 git config --global user.name "zhangsan" git config --global user.email "zhangsan@github.com" 配置账号和邮箱后,提交代码会显示提交人,便于问题追查和 CR 。 想要托管账户,可使用ssh-keygen -t rsa -C "xxx@github.com"生成公钥,把id_rsa.pub内容复制到SSH keys。 常用操作 日常工作中,对于 Git 的掌握用好了,也是一种提效,以下总结一些日常必备的 Git 技能。 远程仓库 克隆远程仓库 git clone <repo-url> 关联远程仓库 git remote add origin <repo-url> 本地已存在的项目关联到已有的仓库中。 代码提交 代码拉取 git pull 文件暂存 git add . 注意后面是一个点,点代表暂存所有,单个文件暂存改成文件路径即可。 本地仓库提交 git commit -m "feat: 开发订单" -a -m 代表提交备注,-a 代表提交所有文件。提交描述可参照下文的提交规范。 代码合并 如果想要把 feature/order 合并到 release # 切换到 release 分支 git checkout release # 合并功能分支到当前 release 分支 git merge feature/order 分支合并是基本操作,合并的过程中很多时候会产生冲突,如果不确定使用谁的代码,最好跟当事者沟通一下。 远程推送 git push <remote> <branch> 如果推送的时候被驳回了,大概率是冲突了,优先解决冲突并从新推送;如果确认使用本地代码,则可以使用 --force 强制推送。 分支操作 查看本地分支:git branch 查看远程分支:git branch -r 查看所有分支:git branch -a 切换分支:git checkout 切换并创建新分支:git checkout -b 删除本地分支:git branch -d 删除远程分支: git push origin : 合并分支:git merge 代码回滚 本地代码丢弃 # 单个文件丢弃 git checkout main.js # 所有文件丢弃 git checkout . 撤销某次提交 如上图,可以看到有 2 条记录,如果想要撤回log 1的提交代码,可使用 git revert <commit-hash> 进行回滚。 git revert 0ac68bc3a99a351b4ef9e475436efffa307e7288 使用 git revert 会生成一条新的提交记录 软回滚 同样以上图为例,如果发现log 2的提交只是部分代码写错了,我们想要撤回来从新修改一下再提交,并不是完全舍弃,可使用 git reset --soft <commit> 进行软回滚。 git reset --soft <commit> 需要注意的是,commit 指要回滚到哪个版本,此版本以后的提交都会被回滚(不包含当前版本),而 revert 只能撤销某一条提交。 回滚以后,从新执行 git status 你会发现这 log 2 文件变成了 modify ,也就是跟刚做完未提交是一个状态,此时我们可以若无其事的正常开发,正常提交。但是推送的时候由于跟远程仓库不一致,我们需要执行--force进行强制推送。 硬回滚 同样以上图为例,如果发现 log 2 完全提交错误,想要舍弃,此时可使用 git reset --hard <commit>进行回滚。 git reset --hard <commit> 执行完硬回滚一下,本地仓库会完全删除 log 2的代码,我们只需要强制推送即可。 软回滚本地会保留提交代码,硬回滚本地会删除对应提交代码。最后都必须通过--force才能推送到远程。为了方便起见,可使用 [HEAD]进行快捷操作,比如:git reset --soft HEAD~3 回退到前3次。 四、规范 多人协作时,为了更好管理分支以及提交日志,我们最好建立相关规范,提高协作效率。 分支规范 分支 介绍 环境 master 仓库默认分支,暂无使用 - release 线上保护分支 Prd release-pre 预发环境分支,用于QA做固定Pre测试 Pre test 测试环境分支,用于QA做固定Test测试 Test feature/xxx 功能开发分支 Dev/Test hotfix/xxx 热修复分支 Pre/Prd release-2022xxx 上线分支;上线当日部署到Pre进行验证,通过以后直接作为上线分支使用 Pre/Prd 日常开发中,对于线上环境、预发环境和测试环境建议使用固定分支进行部署,所有分支以 release 作为基线分支。之所以不用release-pre 或者 release 作为上线分支,是因为上线当天经常因为各种原因导致只有部分feature是可以上线的。上线当天创建 release-20220818 并部署到Pre,如果验证通过直接部署上线,如果部分上线,则直接丢弃,再创建一个新的上线分支即可,避免污染 release 。 提交规范 提交代码时,务必填写提交日志,日志清晰明了,说明本次提交的目的。同时需要遵循一定的提交规范。 <type>(<scope>): <subject> Header:包括三个字段:type(必需)、scope(可选)和 subject(必需)。 Type:提交commit的类别,建议使用下面标识 feat: 加入新特性 fix: 修复bug improvement: 在现有特性上的改进 docs: 更改文档 style: 修改了代码的格式 refactor: 代码重构,不包含 bug 的修复以及新增特性 perf: 提升性能的改动 test: 测试用例的改动 build: 改变了构建系统或者增加了新的依赖 ci: 修改了自动化流程的配置文件或者脚本 chore: 除了源码目录以及测试用例的其他修改 revert: 回退到之前的一个 commit Scope:用于说明 commit 影响的范围,默认可忽略。 Subject:简短精炼的提交描述。 五、超实用技巧 场景一 如果我们正在开发一个功能,突然告知有线上问题,我们做了一半的代码如何保存? # 缓存本地开发文件 git stash # 取出最后一条缓存记录并删除 git stash pop # 查看所有缓存列表 git stash list # 应用某一条缓存 git stash apply stash@{index} 使用 git stash 可以临时存储本地所有变动的文件,并从当前分支删除修改的代码。 切换到 release 创建 hotfix-xxx 修复问题。 切换到 feature/xxx ,执行 git stash pop 还原刚刚开发的代码。 一切都像没发生一样,很丝滑。 场景二 release-pre 合并了 5 次提交,最终QA反馈只有一个功能对应的2次提交可以上线,我们如何把其中 2 次提交代码合并到release? 方法一:把需要上线的功能分支直接合并到 release 进行上线。 方法二:使用git revert 回滚其中不必要的提交。 方法三:使用git cherry-pick挑选需要的提交进行合并。 # 把某一次提交应用到当前分支 git cherry-pick <commit-hash> # 把某几次提交应用到当前分支 git cherry-pick <commit-hash> <commit-hash> # 把某个区间的提交应用到当前分支,注意不包含起始提交 git cherry-pick <start-commit>..<end-commit> 如上图,如果想要挑选 4 和 5 的提交代码到 feature/order 分支,则执行:git cherry-pick 0c20eb75..6938e5e0 即可,注意不包含起始提交,左开右闭。 场景三 某需求做完以后,提测的时候通过git log发现 feature/order 分支有无数的提交记录,如下图,如何在提测的时候,让提交日志更干净? 使用 git rebase -i <start-commit> <end-commit> 注意:左开右闭 执行命令,弹出交互式界面 git rebase -i 6938e5e0 修改指令 我们只需要把最后三次提交的pick指令改为squash或者fixup即可,更多参数可参考如下: pick:保留该commit(缩写:p) reword:保留该commit,但我需要修改该commit的注释(缩写:r) edit:保留该commit, 但我要停下来修改该提交(不仅仅修改注释)(缩写:e) squash:将该commit和前一个commit合并(缩写:s) fixup:将该commit和前一个commit合并,但我不要保留该提交的注释信息(缩写:f) exec:执行shell命令(缩写:x) drop:我要丢弃该commit(缩写:d) 输入 :wq 保存后,会进入到从新修改提交日志界面 我们只需要删除这些日志,从新输入我们本次功能开发的日志即可: 再次输入::wq 保存退出即可,最后查看日志如下: 这个世界真的很美好!!! 场景四 多人协作时,分支提交错综复杂,如何让分支提交变的更加线性? 现实是这样的? 使用 **rebase** 变基前后对比 已知线上分支 release 和 功能分支 feature/order ,当功能分支开发的同时,release 也同步做了修改(hotfix合并进来),此时我们通过 rebase 进行变基 # 切换到功能分支 git checkout feature/order # 执行变基 git rebase release 相当于临时保存 feature/order 分支代码,从新拉取 release 最新代码到本地,然后把 feature/order 合并进来,这个过程称之为变基,如果中间发生冲突,我们需要正常处理冲突,处理完以后,使用 git add .添加到暂存区,使用git rebase --continue继续执行,直到所有冲突解决完为止。如果中途想要放弃,可使用 git rebase --abort 进行终止。 git merge 是以提交的时间为先后顺序,而 git rebase 不同, release 代码提交在前,功能分支提交在后,哪怕功能分支先提交的也不行。

181
life.caoalmost 5 years

手把手教你Monorepo 组件库搭建 —— 第一部分

手把手教你Monorepo 组件库搭建 —— 第一部分 1、组件库总览 1、基本信息 基于 Vue3、Ts、Lerna、Yarn Workspaces 搭建的 Monorepo 组件库 2、相关技能 阅读目标:提前明确目标,有助于提升阅读效率 知识点 思考与收获 Monorepo 了解什么是 Monorepo Lerna 了解 Lerna 的基本使用 及 使用它的意义 Yarn workspace 了解 Yarn workspace 的基本运用 Gulp、Rollup、Vite 熟悉 gulp、rollup、vite 的基本使用 Git Hooks 了解什么是 Git Hooks 以及 husky、 yorkie 工具的使用 Code Formatting 熟悉 Eslint、lint-staged, prettier 的基本配置 Git commit 规范工具 熟悉 commitizen 的基本使用 Unit testing 了解 单元测试 工具使用 Sass 了解 Sass 在组件库的基本使用 StoryBook 了解 StoryBook 的基本使用 ... .... 3、组件库开发目录 4、组件库发布目录 2、组件库管理概述 1、Monorepo 概述 Monorepo 是管理项目代码的一种方式,指在一个项目仓库 (repo) 中管理多个模块/包 (package),不同于比较传统的做法(Multirepo,即每一个 package 都单独用一个仓库来进行管理)。目前几乎我们熟知的仓库都无一例外的采用了monorepo 的方式,比如:Babel、React、Angular、Jest, Umijs、Vue3 、create-react-app、react-router 等。 2、Lerna 概述 Lerna 是一个管理工具,用于管理包含多个软件包(package)的 JavaScript 项目。是 Babel 自己用来维护自己的 Monorepo 并开源出的一个项目。优化维护多包的工作流,解决多个包互相依赖,且发布需要手动维护多个包的问题。 2.1 安装 npm install lerna -g 2.2 初始化 lerna init lerna init 会生成 packages目录 、package.json、 lerna.json 文件 package.json { "name": "root", "private": true, // 表示私有 ,私有的 package 不会被发布 "devDependencies": { "lerna": "^4.0.0" } } lerna.json { "packages": [ "packages/*" ], "version": "0.0.0" } 2.3 lerna 的命令行 命令 功能 lerna bootstrap 安装依赖 lerna clean 删除 node_modules 下的各个包 lerna init 初始化 lerna 库 lerna list 查看本地包列表 lerna changed 显示自上次 release tag 以来有修改的包,选项 list lerna diff 显示自上次 release tag 以来的差异 lerna exec 在每个包下执行任意命令 lerna run 执行各个包下的 package.json 文件中的脚本命令 lerna add 添加依赖 , 相当于 yarn add lerna import 引入 package lerna link 相当于 npm link lerna create 创建 package lerna publish 发布 3、Yarn workspaces Yarn workspace 允许我们使用 Monorepo 的形式来管理项目。我们可以把依赖都安装到根目录下的 node_modules 里面,而不是每个子项目的 node_modules 里面,这样依赖可以为每个子项目共享。 整个项目可以只有根目录下面会有一份 yarn.lock 文件,每一个 workspaces 托管的子项目也会被 link 到 根目录下的 node_modules 里面 ,这样我们就可以直接使用 import 导入对应的子项目,如: import utils from "@flower-design/utils" import "@flower-design/theme-chalk/index.scss" import { button } from '@flower-design/components' 使用 yarn workspaces 后,配置文件有细微的调整 root package.json { "name": "root", "private": true, // 表示私有 ,私有的 package 不会被发布 "workspaces":[ "packages/**" // 表示 packages 下的目录都是一个独立包(子项目) ], "devDependencies": { "lerna": "^4.0.0" } } lerna.json { "packages": [ "packages/*" ], // 这个可以干掉 "version": "0.0.0", "npmClient": "yarn" // 告诉 lerna 使用 yarn 作为 npm client tools "userWorkspaces" : "true" // 告诉 lerna 使用 yarn workspaces } 从上面对 lerna , yarn workspaces 的介绍我们可以发现,从 lerna 与 yarn 的命令行使用层面来讲,两者有很多重合的地方,所以业界普遍使用 yarn 命令行工具,使用 lerna 来进行发布。 3、组件库开发环境搭建 1、创建 flower-design 目录 mkdir -p flower-design 2、初始化 lerna + yarn workspaces cd flower-design lerna init package.json { "name": "root", "private": true, "workspaces":[ "packages/**" ], "devDependencies": { "lerna": "^4.0.0" } } lerna.json { "version": "0.0.0", "npmClient": "yarn", "useWorkspaces": true } 3、创建子包 lerna create components lerna create theme-chalk lerna create utils lerna create flower-design 这样 packages 下面就会有上面的 4 个目录,由于每目录都是独立的包,所以每个 包 下面都会生成一个 package.json 文件,在生成 package.json 文件的时候会要你输入 package name , 为了方便管理我们给 components 、theme-chalk 、utils 的 package name 添加一个 “@flower-design” 的 scope 。 然后 yarn install 下 , 就可以在根目录的 node_modules 下面看见如下几个包: @flower-design/components @flower-design/utils @flower-design/theme-chalk flower-design 后续开发就可以直接通过 import xxx from '@flower-design/xxx' 的方式来使用各个子包,是不是非常方便!!! 4、TS 相关初始化 1、安装 typescript yarn global add typescript // 建议全局安装下,方便使用 cli yarn add typescript -D -W 注意:yarn add packages 时候加 -W flag ,表示允许在工作区根目录下安装包,否则会报错。 Using --ignore-workspace-root-check or -W allows a package to be installed at the workspaces root. This tends not to be desired behaviour, as dependencies are generally expected to be part of a workspace. For example yarn add lerna --ignore-workspace-root-check --dev at the workspaces root would allow lerna to be used within the scripts of the root package.json. 2、生成 tsconfig.json tsc --init tsconfig.json { "compilerOptions": { "module": "ESNext", // 打包模块类型ESNext "declaration": false, // 默认不要声明文件 "noImplicitAny": false, // 支持类型不标注可以默认any "removeComments": true, // 删除注释 "moduleResolution": "node", // 按照node模块来解析 "esModuleInterop": true, // 支持es6,commonjs模块 "jsx": "preserve", // jsx 不转 "noLib": false, // 不处理类库 "target": "es6", // 遵循es6版本 "sourceMap": true, // 生成 map 文件 "lib": ["ESNext", "DOM" ], // 编译时用的库 "allowSyntheticDefaultImports": true, // 允许没有导出的模块中导入 "experimentalDecorators": true, // 装饰器语法 "forceConsistentCasingInFileNames": true, // 强制区分大小写 "resolveJsonModule": true, // 解析json模块 "strict": true, // 是否启动严格模式 "skipLibCheck": true // 跳过类库检测 }, "exclude": [ "node_modules"] } 3、添加TS声明目录 typings npm install vue@next -D -W 在 typings 下新增 vue-shim.d.ts 文件 declare module "*.vue" { import type { DefineComponent } from "vue"; const component: DefineComponent<{}, {}, any>; export default component; } 5、配置 eslint 与 代码风格 1、安装 eslint yarn global add eslint // 建议全局安装,方便后续使用cli yarn add eslint -D -W 2、生成 eslint 配置文件 eslint --init // 这是在生成 .eslint.js 是的选择 // To check syntax and find problems , 检测语法与质量问题 ✔ How would you like to use ESLint? · problems ✔ What type of modules does your project use? · esm ✔ Which framework does your project use? · vue ✔ Does your project use TypeScript? · No / Yes ✔ Where does your code run? · browser, node ✔ What format do you want your config file to be in? · JavaScript // Installing 相关的的包 eslint-plugin-vue@latest, @typescript-eslint/eslint-plugin@latest, @typescript-eslint/parser@latest .eslint.js module.exports = { "env": { "browser": true, "es2021": true, "node": true }, "extends": [ "eslint:recommended", "plugin:vue/essential", "plugin:@typescript-eslint/recommended" ], "parserOptions": { "ecmaVersion": 13, "parser": "@typescript-eslint/parser", "sourceType": "module" }, "plugins": [ "vue", "@typescript-eslint" ], "rules": { } }; 调整 .eslint.js module.exports = { "env": { "browser": true, "es2021": true, "node": true }, "extends": [ // eslint:recommended 是 vue2 推荐用的 "eslint:recommended", // vue3 推荐用这个 "plugin:vue/vue3-recommended", "plugin:vue/essential", "plugin:@typescript-eslint/recommended" ], // 明文指定 parser 为 vue-eslint-parser , 因为 @typescript-eslint/parser 需要用 // vue-eslint-parser 解析 vue 文件,否则会报错 , 具体详情请看:eslint-plugin-vue "parser": "vue-eslint-parser", "parserOptions": { "ecmaVersion": 13, "parser": "@typescript-eslint/parser", "sourceType": "module", // If you are using JSX // you need to enable JSX in your ESLint configuration. "ecmaFeatures": { "jsx": true } }, "plugins": [ "vue", "@typescript-eslint" ], "rules": { } }; 3、使用 Prettier 保证代码风格 为了保证代码风格的统一,我们使用 Prettier 插件来标准化 1、安装 prettier 相关包 Prettier 目前主流行的代码风格格式化工具库 eslint-plugin-prettier 一个 Prettier 风格的 eslint 插件 eslint-config-prettier 一个用于解决Eslint与Prettier的规则冲突包 yarn add prettier eslint-plugin-prettier eslint-config-prettier -D -W 2、修改配置文件 module.exports = { "root" : true, "parser": "vue-eslint-parser", "env": { "browser": true, "es2021": true, "node": true }, "extends": [ "plugin:vue/vue3-recommended", "plugin:vue/essential", "plugin:@typescript-eslint/recommended", "prettier" // 表示从 eslint-config-prettier 启用配置,这会关闭一些与 Prettier 冲突的 ESLint 规则。 ], "parserOptions": { "ecmaVersion": 13, "parser": "@typescript-eslint/parser", "sourceType": "module", "ecmaFeatures": { "jsx": true } }, "plugins": [ "vue", "@typescript-eslint", "prettier" // 表示启用 eslint-plugin-prettier 插件 ], "rules": { } }; 4、新增检测执行脚本 表示使用 eslint 规格检测代码语法、质量问题,使用 prettier 检测代码风格问题 { "name": "root", "private": true, "workspaces": [ "packages/**" ], "scripts": { // 检测 "lint": "eslint . --ext .ts,.vue,.js,.jsx && prettier --check .", // 检测 + 修复 "lint:fix": "eslint . --fix --ext .ts,.vue,.js && prettier --write .", }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^5.8.0", "@typescript-eslint/parser": "^5.8.0", "eslint": "^7.32.0", "eslint-plugin-vue": "^8.2.0", "lerna": "^4.0.0" } } 根据上面的配置都是检测的是 " . " 表示当前的根目录,那么其实我们可以添加一个 .eslintignore 文件来告诉 eslint 那些文件夹下的不必检测 /node_modules/* /dist/* 6、Git 提交日志规范工具 Commitizen 是一个撰写符合 Commit Message 标准的一款工具 1、全局安装 Commitizen yarn global add Commitizen 这样就可以直接使用 git cz 代替 git commit 提交 2、局部安装 Commitizen 、cz-conventional-changelog cz-conventional-changelog 一个供 Commitizen 使用的适配器,个人理解就是供 Commitizen 使用的提交信息展示模板,当然这样模块有很多哦,可以到Commitizen主页查看。 yarn add commitizen cz-conventional-changelog -D -W 这样就是可以在根目录的 package.json 文件上添加 { "name": "root", "private": true, "workspaces": [ "packages/**" ], "scripts": { "commit": "git cz", "lint": "eslint . --ext .ts,.vue,.js,.jsx && prettier --check .", "lint:fix": "eslint . --fix --ext .ts,.vue,.js && prettier --write .", }, "config": { "commitizen": { "path": "cz-conventional-changelog" } }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^5.8.0", "@typescript-eslint/parser": "^5.8.0", "commitizen": "^4.2.4", "cz-conventional-changelog": "^3.3.0", "eslint": "^7.32.0", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-vue": "^8.2.0", "lerna": "^4.0.0", "prettier": "^2.5.1" } } 当使用 git cz 时就提示: Select the type of change that you're committing: (Use arrow keys) ❯ feat: A new feature fix: A bug fix docs: Documentation only changes style: Changes that do not affect ... refactor: A code change that neither fixes a bug nor adds a feature perf: A code change that improves performance 确保 git commit 提交信息的规范性 7、Git Hooks 的 配置 1、什么是 Git Hooks ? Git Hooks 其实就一个 Git Action 的钩子函数, 比如 commit 这个动作 ,在 commit 之前、之后 分别会执行的钩子函数。想象一个下前端 SPA 路由 跳转 之前、之后执行的钩子函数 ,差不多一个意思。 2、Git Hooks 存在的意义 ? 例如:在代码提交之前进行 eslint 校验、 commit 信息的规范校验、运行单元测试等待一系列的操作等等。 3、Git Hooks 工具 目前业界有两个比较主流的 Git Hooks 工具: Husky 目前业界使用最广泛的 Git Hooks 工具 yorkie Vue 的 作者从 Husky 中 fork 的一个分支,在 vue-cli 中用的就是 yorkie 注意:这两玩意水火不容,势不两立,只能使用其一 每个被 git 托管的工程的根目录下都存在一个 .git/hooks 目录,.git/hooks 目录下有一些 *.sample 的文件,其实这些文件就是一些 git 钩子 , 只不过他不是以 .sh 结尾所以不会被的调用执行。需要开发人员主动去添加一些有需要的 *.sh 的 文件,那么 Husky、yorkie 做的无非就是这些工作。 4、Git Hooks 配置 这里 git hooks 配置我们使用 husky,然后使用 lint-staged 来运行配置的任务。 1、首先安装 husky 、lint-staged yarn add husky lint-staged -D -W 2、配置 lint-staged 执行的任务 lint-staged 的概念是在git中暂存的文件上运行已配置的linter(或其他)任务 { "lint-staged": { "*.{vue,js,ts,jsx,tsx}": [ "eslint --fix", "prettier --write" ], "*.{scss,css,json,yaml,yml,md,html}": "prettier --write" } } 3、配置 husky 来执行 lint-staged { "husky": { "hooks": { "pre-commit": "lint-staged" // 同过 husky 执行上面的 lint-staged 配置 } } } 注意 : 这是老版本的 husky 配置,目前新版本配置层面改动较大,新版配置如下: // Install husky // 当然这个上面已经安装了 yarn add husky -D -W // Enable Git hooks // 启用 Git hooks yarn husky install // To automatically have Git hooks enabled after install // 给 root package.json 新增 "postinstall": "husky install" 命令 // 每次 install 后都会自动启用 Git hooks { "private": true, // ← your package is private, you only need postinstall "scripts": { "postinstall": "husky install" } } // 接着创建 pre-commit hooks,用来执行 lint-staged // 这时会在根目录上创建一个 .husky/pre-commmit // 注意:.husky/pre-commmit 要提交到git服务, 这样才能保证所有开发者一致 yarn husky add .husky/pre-commit "npx lint-staged" 具体请看 husky配置 官方文档,以官方文档为准 温馨提示:npm 与 npx 的 区别 npm 是包管理(Management)工具,目的是为了更好的管理各种包 npx 是包执行(Execute)工具,目的是为了更方便的执行各种包, npx 是 npm v5.2.0 引入的命令,在执行包之前会去检测包是否有安装,没有安装就先下载安装在执行包,而 npm 不会,没有安装就直接报错

78
life.caoalmost 5 years

手把手教你Monorepo 组件库搭建 —— 第二部分

手把手教你Monorepo 组件库搭建 —— 第二部分 4、组件库组件开发 1、组件库目录结构预览 2、组件开发案例 接下来给大家演示一个Button组件的开发案例 1、案例组件涉及文件预览 编写前先看下开发一个组件需要动到大概的文件 2、新增 utils/src/withInstall 工具函数 utils/src/width-install.ts import type { App, Plugin } from "vue"; export type SFCWithInstall<T> = T & Plugin; export const withInstall = <T>(sfc: T) => { (sfc as SFCWithInstall<T>).install = (app: App) => { app.component((sfc as any).name, sfc); }; return sfc as SFCWithInstall<T>; }; export default withInstall; 3、新增 theme-chalk/src/button scss文件 1、主题目录预览 2、添加或修改主题变量文件 theme-chalk/src/common/var.scss // Brand Color $--color-primary: #409eff !default; $--color-success: #67c23a !default; $--color-warning: #e6a23c !default; $--color-danger: #f56c6c !default; $--color-info: #909399 !default; // Font Color $--color-text-primary: #303133 !default; $--color-text-regular: #606266 !default; $--color-text-secondary: #909399 !default; $--color-text-placeholder: #c0c4cc !default; // Border Color $--border-color-base: #dcdfe6 !default; $--border-color-light: #e4e7ed !default; $--border-color-lighter: #ebeef5 !default; $--border-color-extra-light: #f2f6fc !default; // Background Color $--color-white: #ffffff !default; $--color-black: #000000 !default; $--background-color-base: #f5f7fa !default; 3、添加或修改BEM规范配置文件 theme-chalk/src/mixins/config.scss $namespace: "f"; $element-separator: "__"; $modifier-separator: "--"; $state-prefix: "is-"; 4、添加或修改Sass 函数文件 theme-chalk/src/mixins/mixin.scss @import "config.scss"; @mixin b($block) { .#{ $namespace + "-" + $block } { @content; } } @mixin e($element) { @at-root { #{ & + $element-separator + $element } { @content; } } } @mixin m($modifier) { @at-root { #{ & + $modifier-separator + $modifier} { @content; } } } @mixin when($state) { @at-root { &.#{$state-prefix + $state} { @content; } } } 5、添加 button.scss theme-chalk/src/button.scss @import 'common/var.scss'; @import 'mixins/mixin.scss'; @mixin type($color, $background-color, $border-color) { color: $color; background: $background-color; border-color: $border-color; } @mixin size($padding1, $padding2, $fontSize) { padding: $padding1 $padding2; font-size: $fontSize; } @mixin plain($color) { color: rgba($color: $color, $alpha: 0.5); background-color: rgba($color: $color, $alpha: 0.1); border: 1px solid rgba($color: $color, $alpha: 0.5); } @include b(button) { display: inline-block; cursor: pointer; border-radius: 6px; border: 1px solid $--border-color-base; display: inline-block; outline: none; border: #fafafa; user-select: none; min-height: 32px; line-height: 1; vertical-align: middle; color: $--color-white; padding: 12px 20px; @include m(primary) { @include type($--color-white, $--color-primary, $--color-primary); @include when(disabled) { border-color: rgba($color: $--color-primary, $alpha: 0.4); background-color: rgba($color: $--color-primary, $alpha: 0.4); } } @include m(success) { @include type($--color-white, $--color-success, $--color-success); @include when(disabled) { border-color: rgba($color: $--color-success, $alpha: 0.4); background-color: rgba($color: $--color-success, $alpha: 0.4); } } @include m(warning) { @include type($--color-white, $--color-warning, $--color-warning); @include when(disabled) { border-color: rgba($color: $--color-warning, $alpha: 0.4); background-color: rgba($color: $--color-warning, $alpha: 0.4); } } @include m(danger) { @include type($--color-white, $--color-danger, $--color-danger); @include when(disabled) { border-color: rgba($color: $--color-danger, $alpha: 0.4); background-color: rgba($color: $--color-danger, $alpha: 0.4); } } @include m(info) { @include type($--color-white, $--color-info, $--color-info); @include when(disabled) { border-color: rgba($color: $--color-info, $alpha: 0.4); background-color: rgba($color: $--color-info, $alpha: 0.4); } } @include m(large) { @include size(16px, 24px, 16px); } @include m(medium) { @include size(12px, 20px, 14px); } @include m(small) { @include size(8px, 16px, 12px); } @include when(disabled) { &, &:hover, &:focus { cursor: not-allowed; } } @include when(round) { border-radius: 20px; padding: 12px 23px; } @include when(primary-plain) { @include plain($--color-primary); } @include when(success-plain) { @include plain($--color-success); } @include when(warning-plain) { @include plain($--color-warning); } @include when(danger-plain) { @include plain($--color-danger); } @include when(info-plain) { @include plain($--color-info); } @include when(loading) { pointer-events: none; } } 6、添加或修改主题入口文件 theme-chalk/src/index.scss @import "common/var.scss"; @import "mixins/mixin.scss"; @import "icon.scss"; @import "button.scss"; 组件TS声明文件: button.ts import { ExtractPropTypes, PropType } from "vue"; type ButtonTypes = "primary" | "success" | "info" | "danger" | "warning"; type ComponentSize = "large" | "medium" | "small"; export const Props = { type: { type: String as PropType<ButtonTypes>, default: "primary", }, size: { type: String as PropType<ComponentSize>, default: "medium", }, disabled: { type: Boolean, default: false, }, loading: { type: Boolean, default: false, }, round: { type: Boolean, default: false, }, plain: { type: Boolean, default: false, }, icon: { type: String, default: "", }, }; export type ButtonProps = ExtractPropTypes<typeof Props>; 组件逻辑文件:button.vue <template> <button :class="classs" :disabled="disabled" @click="handleClick"> <i v-if="loading" class="f-icon-loading"></i> <i v-if="icon && !loading" :class="icon"></i> <span v-if="$slots.default"><slot></slot></span> </button> </template> <script lang="ts"> import { Props } from "./button"; import { defineComponent, computed } from "vue"; export default defineComponent({ name: "FwButton", props: Props, emits: ["click"], setup(props, ctx) { const classs = computed(() => [ "f-button", "f-button--" + props.type, props.size ? "f-button--" + props.size : "", { "is-disabled": props.disabled, "is-loading": props.loading, "is-round": props.round, "is-primary-plain": props.plain && props.type === "primary", "is-success-plain": props.plain && props.type === "success", "is-warning-plain": props.plain && props.type === "warning", "is-info-plain": props.plain && props.type === "info", "is-danger-plain": props.plain && props.type === "danger", }, ]); const handleClick = (e) => { ctx.emit("click", e); }; return { classs, handleClick, }; }, }); </script> 4、新增 components/src/button 组件 1、添加 button.ts 文件 components/button/src/button.ts import { ExtractPropTypes, PropType } from "vue"; type ButtonTypes = "primary" | "success" | "info" | "danger" | "warning"; type ComponentSize = "large" | "medium" | "small"; export const Props = { type: { type: String as PropType<ButtonTypes>, default: "primary", }, size: { type: String as PropType<ComponentSize>, default: "medium", }, disabled: { type: Boolean, default: false, }, loading: { type: Boolean, default: false, }, round: { type: Boolean, default: false, }, plain: { type: Boolean, default: false, }, icon: { type: String, default: "", }, }; export type ButtonProps = ExtractPropTypes<typeof Props>; 2、添加 button.vue 文件 components/button/src/button.vue <template> <button :class="classes" :disabled="disabled" @click="handleClick"> <i v-if="loading" class="f-icon-loading"></i> <i v-if="icon && !loading" :class="icon"></i> <span v-if="$slots.default"><slot></slot></span> </button> </template> <script lang="ts"> import { Props } from "./button"; import { defineComponent, computed } from "vue"; export default defineComponent({ name: "FButton", props: Props, emits: ["click"], setup(props, ctx) { const classes = computed(() => [ "f-button", "f-button--" + props.type, props.size ? "f-button--" + props.size : "", { "is-disabled": props.disabled, "is-loading": props.loading, "is-round": props.round, "is-primary-plain": props.plain && props.type === "primary", "is-success-plain": props.plain && props.type === "success", "is-warning-plain": props.plain && props.type === "warning", "is-info-plain": props.plain && props.type === "info", "is-danger-plain": props.plain && props.type === "danger", }, ]); const handleClick = (e) => { ctx.emit("click", e); }; return { classes, handleClick, }; }, }); </script> 3、添加 button 入口文件 components/button/index.ts import Button from './src/button.vue'; import { withInstall } from '@flower-design/utils/src/with-install'; export const FButton = withInstall(Button); export default FButton; export * from './src/button'; 4、添加 components 包入口文件 export * from "./button"; export * from "./icon"; 5、新增 flower-design 包的入口文件 flower-design/index.ts import type { App } from "vue"; import { FIcon, FButton } from "@flower-design/components"; const components = [FIcon, FButton]; const install = (app: App) => { components.forEach((component) => app.use(component)); }; export * from "@flower-design/components"; export default { install, }; 5、组件库打包构建 1、打包工具相关简介 1、rollup Rollup 是业界公认的打包 library 利器 , 所以它也是这次组件库打包的首选 2、gulp 业界知名组件库 element-plus 、ant-design 等几乎都有的 gulp 的身影,gulp 虽然只提供了编译功能,但是它生态比较强大, 简单易用,方便流程控制,从而简化打包逻辑。特别是在比较大型组件库中可以为开发者带来很大的便利。 3、sucrase Sucrase:超快速的 Babel 替代品,可以实现超快速的开发构建。 Sucrase 没有编译大量的 JS 功能以使其能够在 Internet Explorer 中工作,而是假设您正在使用最新的浏览器或最新的 Node.js 版本进行开发,因此它专注于编译非标准语言扩展:JSX、TypeScript ,和流量。 由于范围更小,Sucrase 可以使用性能更高但可扩展性和可维护性更低的架构。 Sucrase 的解析器是从 Babel 的解析器分叉出来的(所以 Sucrase 欠 Babel 的,没有它就不可能),并将其修剪为 Babel 解决的重点子集。 Sucrase 已经过广泛的测试,编译速度 Sucrase 比 Babel 快大约 20 倍。 4、ts-morph TypeScript编译器API包装器。 提供了一种以编程方式导航和操作TypeScript和JavaScript代码的简便方法。 5、fast-glob 该软件包提供了遍历文件系统的方法,并根据Unix Bash shell使用的规则,返回了与指定模式的定义集匹配的路径名,并进行了一些简化,同时以任意顺序返回结果。 快速,简单,有效。 2、theme-chalk 打包构建 1、目录结构 开发目录 产出目录 2、安装相关依赖 yarn add gulp sucrase sass gulp gulp-sass gulp-clean-css gulp-autoprefixer -D -W 3、build/paths.ts import path from "path"; // 工程 根目录 export const projectRoot = path.resolve(__dirname, "../"); // 资源 产出根目录 export const outputRoot = path.resolve(projectRoot, "dist"); // esm 资源产出资源产出 export const outputEsmRoot = path.resolve(outputRoot, "esm"); // cjs 资源产出资源产出 export const outputCjsRoot = path.resolve(outputRoot, "lib"); // 主题 资源产出根目录 export const outputThemeRoot = path.resolve(outputRoot, "theme-chalk"); // 主题 fonts 资源产出根目录 export const outputThemeFontsRoot = path.resolve(outputRoot, "theme-chalk/fonts"); // esm 资源产出资源产出 export const outputEsmUtilsRoot = path.resolve(outputRoot, "esm/utils/src"); // cjs 资源产出资源产出 export const outputCjsUtilsRoot = path.resolve(outputRoot, "lib/utils/src"); // packages目录 export const packagesRoot = path.resolve(projectRoot, "packages"); // components目录 export const componentsRoot = path.resolve(packagesRoot, "components"); // 主打包入口 export const entryRoot = path.resolve(packagesRoot, "flower-design"); // 工具类根目录 export const utilsRoot = path.resolve(packagesRoot, "utils"); // 主题包根目录 export const themeRoot = path.resolve(packagesRoot, "theme-chalk"); 3、gulp 配置文件 packages/theme-chalk/gulpfile.ts import path from "path"; import dartSass from "sass"; import gulpSass from "gulp-sass"; import cleanCss from "gulp-clean-css"; import { series, src, dest } from "gulp"; import autoprefixer from "gulp-autoprefixer"; import { outputThemeRoot, outputThemeFontsRoot } from "../../build/paths"; function compile() { const sass = gulpSass(dartSass); return src(path.resolve(__dirname, "./src/*.scss")) .pipe(sass.sync()) .pipe(autoprefixer()) // 添加前缀 .pipe(cleanCss()) // 压缩 .pipe(dest(outputThemeRoot)); } function copyfont() { return src(path.resolve(__dirname, "./src/fonts/**")) .pipe(cleanCss()) // 压缩 .pipe(dest(outputThemeFontsRoot)); } export default series(compile, copyfont); 4、theme-chalk 包声明文件 packages/theme-chalk/package.json { "name": "@flower-design/theme-chalk", "version": "0.0.1", "author": "yanpingli <496370242@163.com>", "license": "MIT", "scripts": { "build": "gulp --require sucrase/register/ts" } } 3、utils 打包构建 1、目录结构 开发目录 产出目录 2、安装相关依赖 yarn add sucrase gulp gulp-typescript -D -W 3、build/paths.ts import path from "path"; // 工程根目录 export const projectRoot = path.resolve(__dirname, "../"); // 资源产出根目录 export const outputRoot = path.resolve(projectRoot, "dist"); // esm 资源产出资源产出 export const outputEsmRoot = path.resolve(outputRoot, "esm"); // cjs 资源产出资源产出 export const outputCjsRoot = path.resolve(outputRoot, "lib"); // theme-chalk 资源产出资源产出 export const outputThemeRoot = path.resolve(outputRoot, "theme-chalk"); // esm 资源产出资源产出 export const outputEsmUtilsRoot = path.resolve(outputRoot, "esm/utils/src"); // cjs 资源产出资源产出 export const outputCjsUtilsRoot = path.resolve(outputRoot, "lib/utils/src"); // packages目录 export const packagesRoot = path.resolve(projectRoot, "packages"); // components目录 export const componentsRoot = path.resolve(packagesRoot, "components"); // 主打包入口 export const entryRoot = path.resolve(packagesRoot, "flower-design"); // 工具类根目录 export const utilsRoot = path.resolve(packagesRoot, "utils"); // 主题包根目录 export const themeRoot = path.resolve(packagesRoot, "theme-chalk"); 4、gulp 配置文件 packages/utils/gulpfile.ts import path from "path"; import gulp from "gulp"; import ts from "gulp-typescript"; import { projectRoot, outputCjsUtilsRoot, outputEsmUtilsRoot, } from "../../build/paths"; const tsConfig = path.resolve(projectRoot, "tsconfig.json"); const inputs = ["./src/*.ts"]; const compileEsm = () => { /** * 生成TS文件 */ const project = ts.createProject(tsConfig, { declaration: true, strict: false, module: "ESNext", }); return gulp.src(inputs).pipe(project()).pipe(gulp.dest(outputEsmUtilsRoot)); }; const compileCjs = () => { /** * 生成TS文件 */ const project = ts.createProject(tsConfig, { declaration: true, strict: false, module: "CommonJS", }); return gulp.src(inputs).pipe(project()).pipe(gulp.dest(outputCjsUtilsRoot)); }; export default gulp.parallel(compileEsm, compileCjs); 5、utils 包声明文件 packages/utils/package.json { "name": "@flower-design/utils", "version": "0.0.1", "author": "yanpingli <496370242@163.com>", "license": "MIT", "scripts": { "build": "gulp --require sucrase/register/ts" } } 4、components 打包构建 1、目录结构 开发目录 产出目录 2、安装相关依赖 yarn add gulp sucrase fast-glob ts-morph @vue/compiler-sfc rollup rollup-plugin-vue rollup-plugin-typescript2 @rollup/plugin-commonjs @rollup/plugin-node-resolve -D -W 3、build/paths.ts import path from "path"; // 工程根目录 export const projectRoot = path.resolve(__dirname, "../"); // 资源产出根目录 export const outputRoot = path.resolve(projectRoot, "dist"); // esm 资源产出资源产出 export const outputEsmRoot = path.resolve(outputRoot, "esm"); // cjs 资源产出资源产出 export const outputCjsRoot = path.resolve(outputRoot, "lib"); // theme-chalk 资源产出资源产出 export const outputThemeRoot = path.resolve(outputRoot, "theme-chalk"); // esm 资源产出资源产出 export const outputEsmUtilsRoot = path.resolve(outputRoot, "esm/utils/src"); // cjs 资源产出资源产出 export const outputCjsUtilsRoot = path.resolve(outputRoot, "lib/utils/src"); // packages目录 export const packagesRoot = path.resolve(projectRoot, "packages"); // components目录 export const componentsRoot = path.resolve(packagesRoot, "components"); // 主打包入口 export const entryRoot = path.resolve(packagesRoot, "flower-design"); // 工具类根目录 export const utilsRoot = path.resolve(packagesRoot, "utils"); // 主题包根目录 export const themeRoot = path.resolve(packagesRoot, "theme-chalk"); 4、gulp 配置文件 packages/components/gulpfile.ts import gulp from "gulp"; import path from "path"; import fg from "fast-glob"; import vue from "rollup-plugin-vue"; import ts from "rollup-plugin-typescript2"; import commonjs from "@rollup/plugin-commonjs"; import { nodeResolve } from "@rollup/plugin-node-resolve"; import { rollup, OutputOptions } from "rollup"; import { Project, SourceFile } from "ts-morph"; import * as VueCompiler from "@vue/compiler-sfc"; import fs from "fs/promises"; import { outputRoot, projectRoot, componentsRoot, packagesRoot, } from "../../build/paths"; const pathRewriter = (format) => { return (id) => id.replaceAll("@flower-design", `flower-design/${format}`); }; /** * 构建 packages/components 入口 * 打包 packages/components/index.js */ const buildComponentEntry = async () => { const bundle = await rollup({ input: path.resolve(__dirname, "index.ts"), plugins: [nodeResolve(), ts(), vue(), commonjs()], // 外链组件 external: [/^(\.\/)/], }); const outputOptions = [ { format: "cjs", exports: "named", file: path.resolve(outputRoot, "lib", `components/index.js`), }, { format: "esm", file: path.resolve(outputRoot, "esm", `components/index.js`), }, ]; await Promise.all( outputOptions.map((option) => bundle.write(option as OutputOptions)) ); }; /** * 构建 packages/components 下的组件 * 打包 packages/components/button * 打包 packages/components/icon * ... */ const buildComponent = async () => { const directories = fg.sync("*", { cwd: __dirname, onlyDirectories: true, ignore: ["node_modules"], }); const bundles = directories.map(async (dir) => { const inputOptions = { input: path.resolve(__dirname, dir, "index.ts"), plugins: [nodeResolve(), ts(), vue(), commonjs()], // 排除 vue、@flower-design 开头的引入 external: (id) => /^vue/.test(id) || /^@flower-design/.test(id), }; const outputOptions = [ { format: "cjs", exports: "named", paths: pathRewriter("lib"), file: path.resolve(outputRoot, "lib", `components/${dir}/index.js`), }, { format: "esm", paths: pathRewriter("esm"), file: path.resolve(outputRoot, "esm", `components/${dir}/index.js`), }, ]; const bundle = await rollup(inputOptions); await Promise.all( outputOptions.map((option) => bundle.write(option as OutputOptions)) ); }); return Promise.all(bundles); }; /** * * 产出 *.d.ts 文件 到 dist/types 目录 * 源文件:packages/components 下所有文件 * 目标文件:dist/types/**.d.ts */ const generatorDts = async () => { const project = new Project({ compilerOptions: { strict: false, allowJs: true, skipLibCheck: true, declaration: true, noEmitOnError: false, emitDeclarationOnly: true, rootDir: packagesRoot, outDir: path.resolve(outputRoot, "types"), paths: { "@flower-design/*": ["packages/*"] }, }, skipAddingFilesFromTsConfig: true, tsConfigFilePath: path.resolve(projectRoot, "tsconfig.json"), }); // 排除测试文件 const filePaths = await fg.sync(["**/*.{ts,js,vue}", "!**/*.spec.ts"], { absolute: true, onlyFiles: true, cwd: componentsRoot, ignore: ["gulpfile.ts", "!node_modules"], }); const sourceFiles: SourceFile[] = []; const arr = filePaths.map(async (fpath) => { if (fpath.endsWith(".vue")) { // 如果是 vue 文件 const file = await fs.readFile(fpath, "utf-8"); const sfc = VueCompiler.parse(file); const { script } = sfc.descriptor; if (script) { const sourceFile = project.createSourceFile( `${fpath}.ts`, script.content ); sourceFiles.push(sourceFile); } } else { // 否则就是 js|ts 文件 sourceFiles.push(project.addSourceFileAtPath(fpath)); } }); await Promise.all(arr); await project.emitToMemory({ emitOnlyDtsFiles: true }); const generatorTask = sourceFiles.map(async (source: SourceFile) => { const emitOutput = source.getEmitOutput(); const outputFiles = emitOutput.getOutputFiles(); const writeTask = outputFiles.map(async (file) => { const fpath = file.getFilePath(); // 递归创建文件目录 await fs.mkdir(path.dirname(fpath), { recursive: true }); // 替换文件中的 @flower-design const replace = pathRewriter("esm"); await fs.writeFile(fpath, replace(file.getText())); }); await Promise.all(writeTask); }); await Promise.all(generatorTask); await copyDtsFiles() }; // copy types/*.d.ts 到 lib , esm 下面 const copyDtsFiles = async () => { const lib = path.resolve(outputRoot, "lib/components"); const esm = path.resolve(outputRoot, "esm/components"); const dir = path.resolve(outputRoot, "types/components"); return gulp.src(`${dir}/**/*`).pipe(gulp.dest(lib)).pipe(gulp.dest(esm)); }; export default gulp.parallel(buildComponentEntry, buildComponent, generatorDts); 5、components 包声明文件 packages/components/package.json { "name": "@flower-design/components", "version": "0.0.1", "description": "all flower-design components", "author": "yanpingli <496370242@163.com>", "homepage": "", "license": "MIT", "main": "index.ts", "scripts": { "build": "gulp --require sucrase/register/ts" } } 5、flower-design 打包构建 1、目录结构 开发目录 打包产出目录 2、安装相关依赖 yarn add gulp sucrase fast-glob ts-morph @vue/compiler-sfc rollup rollup-plugin-vue rollup-plugin-typescript2 @rollup/plugin-commonjs @rollup/plugin-node-resolve -D -W 3、build/paths.ts import path from "path"; // 工程根目录 export const projectRoot = path.resolve(__dirname, "../"); // 资源产出根目录 export const outputRoot = path.resolve(projectRoot, "dist"); // esm 资源产出资源产出 export const outputEsmRoot = path.resolve(outputRoot, "esm"); // cjs 资源产出资源产出 export const outputCjsRoot = path.resolve(outputRoot, "lib"); // theme-chalk 资源产出资源产出 export const outputThemeRoot = path.resolve(outputRoot, "theme-chalk"); // esm 资源产出资源产出 export const outputEsmUtilsRoot = path.resolve(outputRoot, "esm/utils/src"); // cjs 资源产出资源产出 export const outputCjsUtilsRoot = path.resolve(outputRoot, "lib/utils/src"); // packages目录 export const packagesRoot = path.resolve(projectRoot, "packages"); // components目录 export const componentsRoot = path.resolve(packagesRoot, "components"); // 主打包入口 export const entryRoot = path.resolve(packagesRoot, "flower-design"); // 工具类根目录 export const utilsRoot = path.resolve(packagesRoot, "utils"); // 主题包根目录 export const themeRoot = path.resolve(packagesRoot, "theme-chalk"); 4、gulp 配置文件 packages/flower-design/gulpfile.ts import path from "path"; import gulp from "gulp"; import { rollup, OutputOptions } from "rollup"; import vue from "rollup-plugin-vue"; import commonjs from "@rollup/plugin-commonjs"; import typescript from "rollup-plugin-typescript2"; import { nodeResolve } from "@rollup/plugin-node-resolve"; import { Project, SourceFile } from "ts-morph"; import fg from "fast-glob"; import fs from "fs/promises"; import { entryRoot, outputCjsRoot, outputEsmRoot, outputRoot, projectRoot, } from "../../build/paths"; /** * 打包总入口 , 产出如下: * 1、dist/index.js * 2、dist/index.esm.js * 特点:打包了组件库所有代码 */ const buildBundle = async () => { const bundleOutputOptions = [ { format: "esm", file: path.resolve(outputRoot, "index.esm.js"), }, { format: "umd", file: path.resolve(outputRoot, "index.js"), name: "FlowerDesign", exports: "named", globals: { vue: "Vue", }, }, ]; const bundle = await rollup({ input: path.resolve(__dirname, "index.ts"), plugins: [nodeResolve(), typescript(), vue(), commonjs()], external: (id) => /^vue/.test(id), }); return Promise.all( bundleOutputOptions.map((option) => { return bundle.write(option as OutputOptions); }) ); }; /** * 打包 esm、lib 入口 , 产出如下: * 1、dist/esm/index.js * 2、dist/lib/index.js * 特点:外链组件库代码 */ const buildEntry = async () => { const inputs = await fg.sync("*.ts", { cwd: __dirname, absolute: true, onlyFiles: true, ignore: ["gulpfile.ts"], }); const bundle = await rollup({ input: inputs, plugins: [nodeResolve(), typescript(), vue(), commonjs()], // 不打包以 vue 、@flower-design 开头的代码 external: (id) => /^vue/.test(id) || /^@flower-design/.test(id), }); /** * 替换 @flower-design 前缀 * @param format esm or lib * @returns */ const pathRewriter = (format) => { return (id: string) => { return id.replaceAll("@flower-design", `flower-design/${format}`); }; }; const outputOptions = [ { format: "cjs", dir: outputCjsRoot, // 输出目录 exports: "named", paths: pathRewriter("lib"), }, { format: "esm", dir: outputEsmRoot, // 输出目录 paths: pathRewriter("esm"), }, ]; return Promise.all( outputOptions.map(async (option) => { await bundle.write(option as OutputOptions); }) ); }; /** * 为 flower-design/index.js 生成 ts 声明文件 * 产出资源为 : types/index.d.ts */ const generatorDts = async () => { const project = new Project({ compilerOptions: { strict: false, allowJs: true, skipLibCheck: true, declaration: true, noEmitOnError: false, emitDeclarationOnly: true, rootDir: entryRoot, // packages/flower-design/ outDir: path.resolve(outputRoot, "types"), // 产出目录 }, skipAddingFilesFromTsConfig: true, tsConfigFilePath: path.resolve(projectRoot, "tsconfig.json"), }); const filePaths = await fg.sync("*.ts", { absolute: true, onlyFiles: true, cwd: __dirname, ignore: ["gulpfile.ts", "!node_modules/**"], }); const sourceFiles: SourceFile[] = []; filePaths.forEach((fpath) => { sourceFiles.push(project.addSourceFileAtPath(fpath)); }); // 会产出资源文件 // await project.emit({ emitOnlyDtsFiles: true}) // 不产出资源文件,发射到内存中 await project.emitToMemory({ emitOnlyDtsFiles: true }); for (const sourceFile of sourceFiles) { const emitOutput = sourceFile.getEmitOutput(); const outputFiles = emitOutput.getOutputFiles(); for (const file of outputFiles) { const fpath = file.getFilePath(); // 递归创建文件目录 await fs.mkdir(path.dirname(fpath), { recursive: true }); // 替换 @flower-design 为 . const content = file.getText().replaceAll("@flower-design", "."); // 写入文件 await fs.writeFile(fpath, content, "utf8"); } } await copyDtsFiles() }; // copy types/*.d.ts 到 lib , esm 下面 const copyDtsFiles = async () => { const lib = path.resolve(outputRoot, "lib"); const esm = path.resolve(outputRoot, "esm"); const dir = path.resolve(outputRoot, "types"); return gulp.src(`${dir}/*`).pipe(gulp.dest(lib)).pipe(gulp.dest(esm)); }; export default gulp.parallel(buildBundle, buildEntry, generatorDts); 5、flower-design 包声明文件 packages/flower-design/package.json { "name": "flower-design", "version": "0.0.1", "description": "A Component Library for Vue 3", "author": "yanpingli <496370242@163.com>", "homepage": "", "license": "MIT", "main": "lib/index.js", "module": "esm/index.js", "style": "theme-chalk/index.css", "unpkg": "index.js", "jsdelivr": "index.js", "keywords": [ "flower-design", "component library", "ui framework", "ui", "vue" ], "scripts": { "build": "gulp --require sucrase/register/ts" }, "peerDependencies": { "vue": "^3.2.0" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" }, "repository": { "type": "git", "url": "https://gitee.com/front-flower/flower-design.git" } } 6、root package.json { "name": "root", "private": true, "workspaces": [ "packages/*" ], "scripts": { "dev": "cd play && yarn dev", "build:theme": "cd packages/theme-chalk && yarn build", "build:utils": "cd packages/utils && yarn build", "build:bundle": "cd packages/flower-design && yarn build", "build:component": "cd packages/components && yarn build", "build": "sh build/build.sh", "clean:dist": "rimraf ./dist", "test": "rimraf ./dist && yarn build:theme && yarn build:utils && yarn build:component && yarn build:bundle", "release": "cd dist && yarn publish", "lint": "eslint . --ext .ts,.vue,.js,.jsx && prettier --check .", "lint:fix": "eslint . --fix --ext .ts,.vue,.js && prettier --write .", "prepare": "husky install", "postinstall": "husky install", "commit": "git cz", "storybook": "start-storybook -p 6006", "build-storybook": "build-storybook" }, "config": { "commitizen": { "path": "cz-conventional-changelog" } }, "lint-staged": { "*.{vue,js,ts,jsx,tsx}": [ "eslint --fix", "prettier --write ." ], "*.{scss,css,json,yaml,yml,md,html}": [ "prettier --write ." ] }, "devDependencies": { "@babel/core": "^7.16.5", "@rollup/plugin-commonjs": "^21.0.0", "@rollup/plugin-node-resolve": "^13.0.5", "@storybook/addon-actions": "^6.4.9", "@storybook/addon-essentials": "^6.4.9", "@storybook/addon-links": "^6.4.9", "@storybook/preset-scss": "^1.0.3", "@storybook/vue3": "^6.4.9", "@types/gulp": "^4.0.9", "@types/gulp-autoprefixer": "^0.0.33", "@types/gulp-clean-css": "^4.3.0", "@types/gulp-sass": "^5.0.0", "@types/sass": "^1.16.1", "@typescript-eslint/eslint-plugin": "^5.6.0", "@typescript-eslint/parser": "^5.6.0", "@vue/compiler-sfc": "^3.2.20", "babel-loader": "^8.2.3", "commitizen": "^4.2.4", "cp-cli": "^2.0.0", "css-loader": "^5.2.2", "cz-conventional-changelog": "^3.3.0", "eslint": "^8.4.1", "eslint-config-prettier": "^8.3.0", "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-vue": "^8.2.0", "fast-glob": "^3.2.7", "gulp": "^4.0.2", "gulp-autoprefixer": "^8.0.0", "gulp-clean-css": "^4.3.0", "gulp-sass": "^5.0.0", "gulp-typescript": "^6.0.0-alpha.1", "husky": "^7.0.4", "lerna": "^4.0.0", "lint-staged": "^12.1.2", "prettier": "^2.5.1", "rimraf": "^3.0.2", "rollup": "^2.58.0", "rollup-plugin-typescript2": "^0.30.0", "rollup-plugin-vue": "^6.0.0", "sass": "^1.45.0", "sass-loader": "^10.1.1", "sb": "^6.4.9", "style-loader": "^2.0.0", "sucrase": "^3.20.3", "ts-morph": "^13.0.2", "typescript": "^4.4.4", "vue": "^3.2.20", "vue-loader": "^16.8.3" }, "peerDependencies": { "vue": "^3.2.0" }, "browserslist": [ "> 1%", "not ie 11", "not op_mini all" ], "dependencies": {} }

67
life.caoalmost 5 years

手把手教你Monorepo 组件库搭建 —— 第三部分

手把手教你Monorepo 组件库搭建 —— 第三部分 7、发布文件 build/build.sh #!/bin/sh set -e # remove dist yarn clean:dist # build theme-chalk yarn build:theme # build:utils yarn build:utils # build components yarn build:component # build components yarn build:bundle # echo "copying source code" cp packages/flower-design/package.json dist/package.json # echo "copying README" cp packages/flower-design/README.md dist/README.md 注意:flower-design 包的 package.json 要 copy 到 dist 目录下 6、Jest 单元测试 单元测试使用 Jest 测试 ,具体配置请看 配置教程 1、安装 Jest 注意:安装 jest、ts-jest 的版本问题,否则可能会有版本兼容问题而导致单元测试报错 yarn add jest@^26.6.3 ts-jest@^26.5.6 vue-jest@next @types/jest @vue/test-utils@next @sucrase/jest-plugin -D -W // "jest": "^26.6.3", // "ts-jest": "^26.5.6", // "vue-jest": "^5.0.0-alpha.10", // "@vue/test-utils": "^2.0.0-rc.18", // "@sucrase/jest-plugin": "^2.2.0" 2、Jest 配置文件 生成 jest.config.ts 文件 npx jest --init 修改配置文件如下: export default { clearMocks: true, roots: ['<rootDir>'], testEnvironment: 'jsdom', testPathIgnorePatterns: ['/node_modules/', 'dist'], modulePathIgnorePatterns: ['/node_modules/', 'dist'], moduleFileExtensions: ['ts', 'tsx', 'js', 'vue', 'json'], testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[tj]s?(x)'], transform: { '\\.(j|t)s$': '@sucrase/jest-plugin', '^.+\\.vue$': 'vue-jest' } }; 4、测试Demo import { mount } from '@vue/test-utils'; import Button from '../src/button.vue'; describe('Button.vue', () => { it('create', () => { const wrapper = mount(Button, { props: { type: 'primary' } }); expect(wrapper.classes()).toContain('f-button--primary'); }); }); 7、Storybook 故事书是一个开源的工具,用于独立开发 React、Vue、Angular 等的 UI 组件。它能有组织且高效地构建 UI 组件,更快地创建一个坚不可摧的 UI 组件库。每一个 UI 组件的示例就是一个 story ,所有的示例的集合就是 Storybook 了 1、安装 Storybook yarn sb init -D -W 安装之后 root package.json 会多出两个 scripts 命令行 "scripts": { "storybook": "start-storybook -p 6006", "build-storybook": "build-storybook" }, 工程根目录下面会多 .storybook 、stories 两个目录 : 1、.storybook .storybook 是 story 的配置目录,主要包含两个文件: main.js 配置 Storybook 的行为信息,还可以在次配置webpack、babel信息。 module.exports = { stories: [ "../stories/**/*.stories.mdx", "../stories/**/*.stories.@(js|jsx|ts|tsx)", // 匹配编写的 stories ], addons: [ "@storybook/addon-links", "@storybook/addon-essentials", "@storybook/preset-scss", // 相关插件的配资 ], // 组件库使用的框架 framework: "@storybook/vue3", // ... }; 支持 scss yarn add @storybook/preset-scss css-loader sass sass-loader style-loader -D -W // 注意包的版本兼容问题,否则会报错 // "@storybook/preset-scss": "^1.0.3", // "css-loader": "^5.2.2", // "sass": "^1.45.0", // "sass-loader": "^10.1.1", // "style-loader": "^2.0.0", preview.js 设置插件的行为信息,比如修改插件主题色。 export const parameters = { actions: { argTypesRegex: "^on[A-Z].*" }, controls: { matchers: { color: /(background|color)$/i, date: /Date$/, }, }, }; 2、stories 主要用来存放编写的 组件示例、组件文档 2、编写 story 具体编写,配置细节等等,请看 官方文档示例 8、发布 对于一个大型的组件库而言如何管理组织工程目录结构是非常重要,它能为开发和维护带来很大的便利从而提高开发效率。虽然我们采用的是 Monorepo 的方式进行开发,但是发布的时候其实并不见得就一定要发布多个包 (仅发布 ”dist“ 就可),所以最后我们可以不采用 lerna 发布 发布之前如果您没有注册 NPM 账号那么首先去NPM官网注册账号。 1、在控制台登录 npm npm login 2、输入登录信息 Username : ypl Password : yplXXXX Email : (this is public) xxxxx@qq.com 注意:登录的时候源必须是 https://registry.npmjs.org,否则你永远登录不上 3、执行发布命令 1、dist/package.json { "name": "flower-design", "version": "0.0.1", "description": "A Component Library for Vue 3", "author": "yanpingli <496370242@163.com>", "homepage": "", "license": "MIT", "main": "lib/index.js", "module": "esm/index.js", "style": "theme-chalk/index.css", "unpkg": "index.js", "jsdelivr": "index.js", "keywords": [ "flower-design", "component library", "ui framework", "ui", "vue" ], "scripts": { "build": "gulp --require sucrase/register/ts" }, "peerDependencies": { "vue": "^3.2.0" }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" }, "repository": { "type": "git", "url": "https://gitee.com/front-flower/flower-design.git" } } 注意:publishConfig 字段的配置,加上 access : "public", registry 必须是 https://registry.npmjs.org 2、package.json "scripts": { "dev": "cd play && yarn dev", "build:theme": "cd packages/theme-chalk && yarn build", "build:utils": "cd packages/utils && yarn build", "build:bundle": "cd packages/flower-design && yarn build", "build:component": "cd packages/components && yarn build", "build": "sh build/build.sh", "clean:dist": "rimraf ./dist", "test": "jest", "release": "cd dist && yarn publish", "lint": "eslint . --ext .ts,.vue,.js,.jsx && prettier --check .", "lint:fix": "eslint . --fix --ext .ts,.vue,.js && prettier --write .", "prepare": "husky install", "postinstall": "husky install", "commit": "git cz", "storybook": "start-storybook -p 6006", "build-storybook": "build-storybook" }, 3、运行发布命令 yarn release 4、发布 输入最新版本号,按下 enter 键,搞定!!!!

59
life.caoalmost 5 years

前端单元测试和集成测试

前端单元测试和集成测试 请问在一个多人长期维护的项目中,你是如何保证代码质量的? 产品需求 在线产品文档 技术方案 在线技术方案 开发准备 git clone https://github.com/ranwawa/test-FE-react cd test-FE-react npm install 开发思路 沿着从小到大,从局部到整体的思路开发. 编写验证手机号和密码的函数 封装手机号和密码的ui组件 封装登录状态的context 二次封装axios请求函数 编写登录页面 编写登录逻辑 1. 单元测试 通常只针对自己开发的一个小功能进行测试,不需要和其他插件/模块/函数进行交互.一般只需要一个断言即可完成测试 1.1 测试一个函数 1.1.1 编写业务代码 通过正则来验手机号这个函数在找回密码,创建帐号等地方会用到,所以抽离到utils/index.js文件作为公共函数 // src/utils/index.js export const REG_MOBILE = /1[3-8]\d{9,9}/; /** * 验证是否手机号 * @param {string} mobile - 手机号码 * @returns {boolean} */ export const isMobile = function (mobile) { return REG_MOBILE.test(mobile); } 1.1.2 编写测试代码 创建测试目录utils/__test__ 创建测试文件utils/__test__/index.test.js 新增测试分组describe('验证手机号码函数相关测试', ... 编写测试用例新增测试用例test('输入正确的手机号码', ... 运行函数 断言函数结果expect(...).to... 运行测试命令npm run test,检查测试结果 // src/utils/__tests__/index.test.js import {isMobile } from '..'; describe('验证手机号码函数相关测试', () => { test('输入正确的手机号码:13333333333,应该返回true', () => { const res = isMobile('13333333333') expect(res).toBe(true); }); test('输入错误的手机号码:1333333,应该返回false', () => { const res = isMobile('1333333') expect(res).toBe(false); }) }); 1.1.3 测试结果 PASS src/utils/__tests__/index.test.js 验证手机号码函数相关测试 ✓ 输入正确的手机号码:13333333333,应该返回true (2 ms) ✓ 输入错误的手机号码:1333333,应该返回false 1.1.4 试一试 将第一个测试用例的验证函数.toBe(true)修改成.toBe(false) 将第2个测试用例的验证函数.toBe(false)修改成.toBeFalsy() 1.2 测试快照 针对正则的常量,我们可以保存一个快照.当修改常量时,会进行提示,以避免不小心被修改错了 1.2.1 编写测试代码 定位到验证手机号码的测试分组 新增测试用例 运行测试命令 // src/utils/__tests__/index.test.js + import {isMobile, REG_MOBILE } from '..'; describe('验证手机号码函数相关测试', () => { test('输入正确的手机号码:13333333333,应该返回true', () => { const res = isMobile('13333333333') expect(res).toBe(true); }); test('输入错误的手机号码:1333333,应该返回false', () => { const res = isMobile('1333333') expect(res).toBe(false); }) + test('手机号码的正则表达式应该是11位数字', () => { + expect(REG_MOBILE).toMatchSnapshot(); + }) }); 1.2.2 测试结果 PASS src/utils/__tests__/index.test.js 验证手机号码函数相关测试 ✓ 输入正确的手机号码:13333333333,应该返回true (3 ms) ✓ 输入错误的手机号码:1333333,应该返回false (1 ms) ✓ 手机号码的正则表达式应该是11位数字 (2 ms) › 1 snapshot written. Snapshot Summary › 1 snapshot written from 1 test suite. 1.2.3 试一试 看看src/utils/__tests__/__snapshots__ 将断言函数expect(REG_MOBILE)修改成expect('1[0-9]') 1.3 测试组件 1.3.1 编写业务代码 手机输入框组件在注册,修改手机号时也会用到,所以抽离成一个公共组件 // src/components/Mobile.js import { Form, Input } from 'antd'; const Mobile = () => { return ( <Form.Item label='用户名' name='username' > <Input placeholder='请输入手机号' /> </Form.Item> ); }; export default Mobile; 1.3.2 编写测试代码 引入第3方测试库 模拟全局变量 编写测试用例新增测试分组和用例 渲染组件 断言组件渲染结果 运行测试命令 // src/components/__tests__/Mobile.test.js import { render, screen } from '@testing-library/react'; import '@testing-library/jest-dom'; import { Form } from 'antd'; import Mobile from '../Mobile'; Object.defineProperty(window, 'matchMedia', { value: () => ({ addListener: () => {}, removeListener: () => {}, }), }); describe('手机号输入框相关测试', () => { test('组件渲染成功后,界面上要显示用户名及请输入手机号', () => { render( <Form> <Mobile></Mobile> </Form> ); expect(screen.getByText('用户名')).toBeInTheDocument(); expect(screen.getByPlaceholderText('请输入手机号')).toBeInTheDocument(); }); }); 1.3.3 测试结果 PASS src/components/__tests__/Mobile.test.js 手机号输入框相关测试 ✓ 组件渲染成功后,界面上要显示用户名及请输入手机号码 (58 ms) 1.3.4 试一试 删除测试库'import '@testing-library/jest-dom';' 删除全局属性声明Object.defineProperty(window 将断言内容expect(screen.getByText('用户名'))修改成expect(screen.getByText('密码')) 1.4 测试用户交互 在手机号组件上,添加用户操作相关的逻辑,然后验证用户的操作是否会产生符合期望的结果 1.4.1 编写业务代码 为方便测试,先把state管理写到组件里面,后面再通过props传递 // src/components/Mobile.js import { Form, Input } from 'antd'; + import { useState } from 'react'; + import { isMobile } from '../utils'; const Mobile = () => { + const [value, setValue] = useState(''); + const [err, setErr] = useState(''); + function handleInputChange(e) { + setErr(''); + setValue(e.target.value); + } + function handleInputBlur(e) { + if (value === '') { + setErr(''); + } else if (!isMobile(value)) { + setErr('手机号码格式有误'); + } + } return ( <Form.Item label='用户名' name='username' + validateStatus={err ? 'error' : ''} + help={err} > <Input placeholder='请输入手机号' + value={value} + onChange={handleInputChange} + onBlur={handleInputBlur} /> </Form.Item> ); }; export default Mobile; 1.4.2 编写测试代码 引入测试库 新增测试用例渲染组件 模拟用户操作 断言操作后的结果 运行测试命令 // src/components/__tests__/Mobile.test.js + import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import { Form } from 'antd'; import Mobile from '../Mobile'; Object.defineProperty(window, 'matchMedia', { value: () => ({ addListener: () => {}, removeListener: () => {}, }), }); describe('手机号输入框相关测试', () => { test('组件渲染成功后,界面上要显示用户名及请输入手机号码', () => { render( <Form> <Mobile></Mobile> </Form> ); expect(screen.getByText('用户名')).toBeInTheDocument(); expect(screen.getByPlaceholderText('请输入手机号')).toBeInTheDocument(); }); }); + describe.only('用户操作相关测试', () => { + test('输入错误的手机号码,界面上要显示手机号码格式有误', () => { + render( + <Form> + <Mobile></Mobile> + </Form> + ); + const input = screen.getByPlaceholderText('请输入手机号'); + fireEvent.change(input, { target: { value: '133' } }); + fireEvent.blur(input); + expect(screen.getByText('手机号码格式有误')).toBeInTheDocument(); + }); + test('手机号码输错后,再重新输入手机号码,要清空错误信息', async () => { + render( + <Form> + <Mobile></Mobile> + </Form> + ); + const input = screen.getByPlaceholderText('请输入手机号'); + fireEvent.change(input, { target: { value: '133' } }); + fireEvent.blur(input); + + expect(screen.getByText('手机号码格式有误')).toBeInTheDocument(); + fireEvent.change(input, { target: { value: '' } }); + await waitFor(() => { + expect(screen.queryByText('手机号码格式有误')).not.toBeInTheDocument(); + }); + }); }); 1.4.3 测试结果 PASS src/components/__tests__/Mobile.test.js 手机号输入框相关测试 ○ skipped 组件渲染成功后,界面上要显示用户名及请输入手机号码 用户操作相关测试 ✓ 输入错误的手机号码,界面上要显示手机号码格式有误 (76 ms) ✓ 手机号码输错后,再重新输入手机号码,要清空错误信息 (33 ms) 1.4.4 试一试 删除测试分组后面的.isOnly函数 删除异步等待的包裹函数await waitFor(() => ... 2. 集成测试 通常需要和外部库,其他依赖,用户操作一起进行测试 2.1 测试接口 对axios进行二次封装,接口请求失败或后端返回的状态码不是0,需要重新格式化返回的数据 2.1.1 编写业务代码 屏蔽掉Promise的reject状态,通过express风格处理接口响应 // src/api/index.js import axios from 'axios'; /** * 二次封装的请求函数 * @param {string} path - 接口路由 * @param {object} params - 请求参数 * @returns {Promise<([null, object] | [object | null])>} */ export const request = async function (path, params = {}) { try { const url = `test.com/${path}`; const res = await axios.get(url, { params }); if (res?.ret !== 0) { return [res, null]; } return [null, res.data]; } catch (error) { return [error, null]; } }; export default request; 2.1.2 编写测试代码 创建测试文件 新增测试用例模拟依赖包jest.spyOn(axios, 'get') 模拟依赖包响应数据spyGet.mockRejectedValue(... 运行异步函数 断言运行结果 运行测试命令 // src/api/__tests__/index.test.js import axios from 'axios'; import request from '../index'; const spyGet = jest.spyOn(axios, 'get'); describe('公共请求库相关测试', () => { test('如果http链接建立失败,测返回错误', async () => { spyGet.mockRejectedValue(new Error('请求超时')); const [err, res] = await request('login', { name: '13355556666', password: '123456', }); expect(err).toEqual(new Error('请求超时')); expect(res).toBe(null); }); test('如果后端返回的状态码是1,则返回错误', async () => { spyGet.mockResolvedValue({ ret: 1, data: {} }); const [err, res] = await request('login', { name: '13355556666', password: '123456', }); expect(err).toEqual({ ret: 1, data: {} }); expect(res).toBeNull(); }); test('如果后端返回的状态码是0,则取后端返回的data数据', async () => { spyGet.mockResolvedValue({ ret: 0, data: { token: 'token' } }); const [err, res] = await request('login', { name: '13355556666', password: '123456', }); expect(err).toBe(null); expect(res).toEqual({ token: 'token' }); }); }); 2.1.3 测试结果 PASS src/api/__tests__/index.test.js 公共请求库相关测试 ✓ 如果http链接建立失败,测返回错误 (4 ms) ✓ 如果后端返回的状态码是1,则返回错误 (2 ms) ✓ 如果后端返回的状态码是0,则取后端返回的data数据 (2 ms) 2.1.4 试一试 删除模拟响应结果spyGet.mockResolvedValue({ ret: 1, data: {} }) 将最后一个断言的验证函数toEqual({ token: 'token' })修改成toBe({ token: 'token' }) 2.2 测试路由跳转 路由是使用的react-router,在测试路由跳转时,必须结合react-router一起进行测试 2.2.1 编写业务代码 新增路入口文件 在登录页面添加一个跳转链接 // src/App.js import { Routes, Route } from 'react-router-dom'; import { Login } from './Login'; export const App = () => { return <Routes> <Route path='/login' element={<Login />} /> <Route path='/forgot' element='忘记密码页面' /> </Routes> } export default App // src/Login.js import sensors from 'sa-sdk-javascript' import { Link } from "react-router-dom"; import { Form } from 'antd'; import Mobile from './components/Mobile'; export function Login() { return ( <Form> <Mobile /> <Link to="/forgot" onClick={() => sensors.track('forgot')}>忘记密码?</Link> </Form> ); } 2.2.2 编写测试代码 创建测试文件 新增测试用例引入相关依赖 模拟全局变量 模拟依赖包 渲染被包裹起来的组件 模拟用户操作 断言操作结果 运行测试命令 // src/__tests__/Login.test.jsx import { fireEvent, render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import '@testing-library/jest-dom'; import App from '../App' const mockTrack = jest.fn() jest.mock('sa-sdk-javascript', () => ({ track: (...params) => mockTrack(...params) })) Object.defineProperty(window, 'matchMedia', { value: () => ({ addListener: () => { }, removeListener: () => { }, }), }); describe('忘记密码相关测试', () => { test('点击忘记密码,要上报forgot神策事件', () => { render(<MemoryRouter initialEntries={['/login']}> <App /> </MemoryRouter>) fireEvent.click(screen.getByText('忘记密码?')) expect(mockTrack).toBeCalledTimes(1) expect(mockTrack).toHaveBeenCalledWith('forgot'); }); test('点击忘记密码,要跳转到忘记密码页面', () => { render(<MemoryRouter initialEntries={['/login']}> <App /> </MemoryRouter>) fireEvent.click(screen.getByText('忘记密码?')) expect(screen.getByText('忘记密码页面')).toBeInTheDocument() }); }); 2.2.3 测试结果 PASS src/__tests__/Login.test.jsx 忘记相关测试 ✓ 点击忘记密码,要上报forgot神策事件 (78 ms) ✓ 点击忘记密码,要跳转到忘记密码页面 (18 ms) 2.2.4 试一试 删除神策模拟jest.mock('sa-sdk-javascript'... 删除包裹层<MemoryRouter... 2.3 测试自动登录 需要结合localStorage,context和react-router一起进行验证 2.3.1 编写业务代码 新增一个context维护token 新增个人中心页面路由 登录页面引入对context的依赖 将Mobile组件的状态管理通过props传递 // src/context/Token.jsx import React, { useState } from 'react' import { useEffect } from 'react' export const TokenContext = React.createContext('') export const Token = ({ children }) => { const [ token, setToken ] = useState('') const storageToken = (mobile) => { localStorage.setItem('token', mobile) setToken(mobile) } useEffect(() => { setToken(localStorage.getItem('token') || '') }, [setToken]) return <TokenContext.Provider value={{ token, storageToken }}> {children} </TokenContext.Provider> } // src/App.js import { Routes, Route } from 'react-router-dom'; + import { Token } from './context/Token'; import { Login } from './Login'; export const App = () => { return ( + <Token> <Routes> + <Route path='/profile' element="个人中心页面" /> <Route path='/login' element={<Login />} /> <Route path='/forgot' element="忘记密码页面" /> </Routes> + </Token>) } export default App // src/Login.js + import { useContext, useEffect, useState } from 'react'; import sensors from 'sa-sdk-javascript' + import { Link, useNavigate } from "react-router-dom"; + import { Form, Button } from 'antd'; import Mobile from './components/Mobile'; + import { TokenContext } from './context/Token'; export function Login() { + const [mobile, setMobile] = useState(''); + const { token, storageToken } = useContext(TokenContext) + const navigate = useNavigate() + useEffect(() => { + token && navigate('/profile') + }, [token, navigate]) return ( <Form> + <Mobile value={mobile} setValue={setMobile}/> <Link to="/forgot" onClick={() => sensors.track('forgot')}>忘记密码?</Link> + <Button disabled={!mobile} onClick={() => storageToken(mobile)}>登录</Button> </Form> ); } // src/components/Mobile.js const Mobile = ({ value, setValue }) => { - const [value, setValue] = useState(''); + const [err, setErr] = useState(''); 2.3.2 编写测试代码 模拟全局变量 模拟用户操作 断言操作结果 // src/__tests__/Login2.test.jsx import { fireEvent, render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; import '@testing-library/jest-dom'; import App from '../App' Object.defineProperty(window, 'matchMedia', { value: () => ({ addListener: () => { }, removeListener: () => { }, }), }); const mockGetItem = jest.fn() const mockSetItem = jest.fn() Object.defineProperty(window, 'localStorage', { value: { getItem: () => mockGetItem(), setItem: (...params) => mockSetItem(...params), }, }); describe('自动登录相关测试', () => { test('如果storage中没有token,则停留在登录页面', () => { mockGetItem.mockReturnValueOnce(undefined) render(<MemoryRouter initialEntries={['/login']}> <App /> </MemoryRouter>) expect(screen.getByText(/忘记密码/)).toBeInTheDocument(); }); test('点击登录按钮,要把token缓存到storage中,然后跳转个人中心页面', () => { mockGetItem.mockReturnValueOnce(undefined) render(<MemoryRouter initialEntries={['/login']}> <App /> </MemoryRouter>) expect(screen.getByRole('button')).toHaveAttribute('disabled') fireEvent.input(screen.getByPlaceholderText('请输入手机号'), { target: { value: '13883198388' } }) fireEvent.change(screen.getByPlaceholderText('请输入手机号')) expect(screen.getByRole('button')).not.toHaveAttribute('disabled') fireEvent.click(screen.getByRole('button')) expect(mockSetItem).toHaveBeenCalledWith('token', '13883198388'); expect(screen.getByText('个人中心页面')).toBeInTheDocument(); }); test('如果storage中有token,则直接跳转个人中心页面', () => { mockGetItem.mockReturnValueOnce('13883198388') render(<MemoryRouter initialEntries={['/login']}> <App /> </MemoryRouter>) expect(screen.getByText('个人中心页面')).toBeInTheDocument(); }); }); 2.3.3 运行测试命令 PASS src/__tests__/Login2.test.jsx 自动登录相关测试 ✓ 如果storage中没有token,则停留在登录页面 (71 ms) ✓ 点击登录按钮,要把token缓存到storage中,然后跳转个人中心页面 (131 ms) ✓ 如果storage中有token,则直接跳转个人中心页面 (17 ms) 2.3.4 试一试 将模拟全局变量中的getItem: () => mockGetItem...修改成getItem: mockGetItem

14
life.caoabout 5 years

带你徒手开Webpack实现原理(1)——AST和Loader

带你徒手开Webpack实现原理——AST和Loader 一、概述 Webpack 是一个用于现代 Javascript 应用程序的静态模块打包工具,它以配置的入口文件为起点,根据文件的依赖关系进行依赖分析,会在内部构建出一个依赖关系图,最后根据依赖关系图生成一个或多个静态资源。 二、依赖分析 从上面对Wepack概述来讲我们提取了一个 “依赖分析” 的关键词,如果以配置的 入口文件 为依赖分析的起点,那么分析这个 入口文件 的依赖模块的切入点又是什么呢 ?那如果入口文件的 依赖模块 又 依赖了其他模块,那我们又以什么样的思路或原则去分析呢 ? 1、依赖分析的切入点 2、依赖分析的原则 以深度优先原则进行依赖分析 二、抽象语法树 抽象语法树(Abstract Syntax Tree),简称语法树 (Syntax tree ,AST),是源代码语法结构的一种抽象表示。它以树状的形式表现 编程语言 的语法结构,树上的每个节点都表示源代码中的一种结构。 例如: const person = require('./src/person.js') 转换抽象语法树(Abstract Syntax Tree)之后: 转换抽象语法树(Abstract Syntax Tree)中可以清楚的看到里面对源代码语法结构的描述,其中: “VariableDeclaration” 表示声明关键字, kind 表示声明的关键字的值 const “VariableDeclarator” 表示声明变量,变量 name 叫 person , 是一个 “Identifier” 类型 "CallExpression" 表示一个函数调用表达式,表达式 name 叫 require , 是一个 “Identifier” 类型 "arguments" 表示一个函数调用表达式的参数,值是 “./src/person.js”,是一个 StringLiteral类型 从上面的结构看,AST 只不过是一个普通的对象而已,对一个对象而言,我们肯定是可以对它进行的访问、修改的。竟然可以访问那么就可以做以下事情: 从AST中提取 CallExpression 的 arguments 的 value 值 ( 一个相对路径), 把提取的相对路径 转换成 绝对路径 在根据绝对路径去读取文件内容转换成AST 在从AST中提取依赖模块 就这样反复循环迭代直到依赖分析完毕,最后不就得到一个依赖关系图。 下面我们用 babel 来实现下面这张图的依赖关系: 简单的代码Demo const fs = require('fs'); const path = require('path'); const babelParser = require('@babel/parser'); const traverse = require('@babel/traverse').default; const entry = './src/app.js' const entryAbsPath = path.posix.join(process.cwd(), entry) const tree = { path: entryAbsPath } function absPath(dPath){ if (path.isAbsolute(dPath)) { return dPath }else { // 入口文件中的依赖模块都是相对于入口文件 // 所以先获得 入口文件path 与 依赖文件(fpath) dirname const dirname = path.posix.dirname(entryAbsPath,dPath) // 在把 dirname、fpath join 就得到了 fpath 的 abs path return path.posix.join(dirname,dPath) } } /** * 1、把相对路径换成绝对路径 * 2、通过绝对路径读取源代代码 * 3、把源代码转成 AST * @param {*} url 文件相对路径 * @returns AST */ function getAST( absPath ){ // 通过相对路径读取源代码 const code = fs.readFileSync(absPath,'utf-8') // 把源代码转换成AST const ast = babelParser.parse(code); return ast } /** * 遍历 AST * 0、访问者模式 * 1、traverse(ast,options) * @param {*} ast * @returns 当前文件依赖的模块 */ function traverseAST(ast){ const deps = [] // 1、遍历 AST (访问者模式) // 2、options 配上访问的节点,AST遇到每个配置的节点都会执行配置函数 traverse(ast, { // 配置访问节点,遍历 AST时 每次遇到 CallExpression ,都会执行配置函数 CallExpression(path) { const { node } = path // 如果 CallExpression 是 require 表达式 // require 是 CallExpression, 但是 CallExpression 表达式不一定是 require if(node.callee.name === 'require') { const { arguments } = node deps.push({ path : absPath(arguments[0].value)}) } } }); return deps } // 遍历 proccess function proccess(dPath,obj,callback) { // 获取 AST const ast = getAST(dPath) // 遍历 AST , 获取依赖 obj.deps = traverseAST(ast) // 遍历完成,执行回调 callback(obj.deps) } // 开始迭代遍历 function run(path,obj) { proccess(path,obj,( deps = [] ) => { // 检测是否存在依赖 if( deps.length > 0 ) { // 继续遍历 deps.forEach(dep => run(dep.path,dep)); } }) } // 开始运行 run(entryAbsPath,tree) console.log(JSON.stringify(tree,null,2)) // { // "path": "/Users/yanpingli/learn/sharing/src/app.js", // "deps": [ // { // "path": "/Users/yanpingli/learn/sharing/src/a.js", // "deps": [ // { // "path": "/Users/yanpingli/learn/sharing/src/a1.js", // "deps": [ // { // "path": "/Users/yanpingli/learn/sharing/src/a11.js", // "deps": [ // { // "path": "/Users/yanpingli/learn/sharing/src/a111.js", // "deps": [] // } // ] // } // ] // }, // { // "path": "/Users/yanpingli/learn/sharing/src/a2.js", // "deps": [] // } // ] // }, // { // "path": "/Users/yanpingli/learn/sharing/src/b.js", // "deps": [] // }, // { // "path": "/Users/yanpingli/learn/sharing/src/c.js", // "deps": [] // } // ] // } 通过这个例子得知我们可以通过访问 AST 的关键节点,然后通过对这些关键节点进行相关的操作,来实现我们目的。 接下来就对 @babel AST 的相关API进行简单的介绍下。 1、@babel AST 常用库 2、创建 CallExpression 表达式 首先创建CallExpression的API是 : types.callExpression(callee, arguments); 其次通过下图得出这里的 callee 是一个Identifier , arguments 是一个 StringLiteral // 1、创建 identifier const identifier = types.identifier('require'); // 2、创建 stringLiteral const stringLiteral = types.stringLiteral('./src/app.js') // 3、创建 CallExpression const expression = types.CallExpression(identifier,[stringLiteral]) // 4、输出结果 console.log(expression) // { // type: 'CallExpression', // callee: { type: 'Identifier', name: 'require' }, // arguments: [ { type: 'StringLiteral', value: './src/app.js' } ] // } 3、替换 require 为 webpack_require const { join } = require('path'); // 代码解析器 : 通过该模块来解析我们的代码生成AST抽象语法树 const parser = require('@babel/parser'); // AST 遍历器 :通过该模块来对 AST抽象语法树 进行递归遍历 const traverse = require('@babel/traverse').default; // AST节点类型: 通过该模块对具体的AST节点进行进行增、删、改、查 const types = require('@babel/types'); // 代码生成器 : 通过该模块可以 将修改后的AST 生成新的代码 const generate = require('@babel/generator').default; // 源代码 const codeSource = `const app = require('./src/app.js')` // 通过源代码生成 AST const ast = parser.parse(codeSource) // 创建 AST 的访问者对象 const visit = { CallExpression(path){ const { node } = path if (node.callee.name === 'require') { // 1、现在要替换 require 为 __webpack_require__ node.callee.name = '__webpack_require__'; // 拿到相对路径 const relativePath = node.arguments[0].value; // 通过相对路径 拿到 绝对路径 const absolutionPath = join(__dirname,relativePath) // 通过 types 创建一个绝对路径的的 stringLiteral const absLiteral = types.stringLiteral(absolutionPath) // 更换原来的 arguments node.arguments = [absLiteral]; } } } // 遍历 AST traverse(ast,visit) // 通过更改后的 AST 重新生成新的代码 const { code } = generate(ast) // 输出结果 console.log(code) // const app = __webpack_require__("/Users/yanpingli/learn/sharing/src/app.js"); AST 转换测试网址:https://astexplorer.net/ babel AST 官方文档 :https://babeljs.io/docs/en/babel-parser 三、Loader 1、Loader 的初衷 从上面这张图我们可以了解到,依赖模块的文件类型五花八门,但是浏览器宿主能够识别的仅仅是 js、css、图片等而已 。Webpack 在实现的过程中肯定会遇到这个问题 。 作为一个前端开发人员我们都知道第三方扩展文件类型最终都要转换成浏览器能够识别的文件 , 那么这个时候势必会涉及到一个文件转换的过程,那么在转换之前需要做的就是要拿到转换的资源内容,仔细一想会发现在做依赖分析的时候会去读取依赖模块的资源内容,那么在这个依赖分析过程中去做转换处理会不会是个合适的契机呢 ? 答案是肯定的,因为 Webpack 就是这么干的。当然在做这件事的时候有两个值得关注的点: Webpack 推崇的是万物皆模块,而现实是 JS 只能识别 .js、.json 文件 Webpack 是如何知道那些文件要做转换 ?如何转换 ? 所以我们就可以从上面的关注点中分析出大体的时间思路: 关键点 1 : 编写一个 接收 转换的资源内容 的 函数 返回一段 导出资源内容的 JS 代码 Demo function transfrom(source){ // 转换资源 const newSource = transformSource(source) // 返回一段导出 转换后资源 的代码 return ` module.export = ${ JSON.stringify(newSource) }` } 这可以解决 Webpack 推崇的是万物皆模块问题 关键点 2 : 告诉 webpack 那些类型的依赖要做转换 告诉 webpack 使用什么方法或规则转换 { test: /\.less$/i, // 告诉 webpack .less 结尾的依赖要转换 loader: [ "less-loader", // 告诉 webpack 使用 less-loader 转换 ], } 讲到到这里 loader 的雏形已经基本已经清晰了,接下来具体的介绍下 Loader 如何编写 2、Loader 的外衣 loader 是一个函数,接收要转换的资源文件,返回处理后的文件 loader 函数是通过 loader-runner 来执行的,loader 函数的 this 指向 loader-runner 执行的上下文 loader 函数可以有个 pitch 函数,用来做熔断的作用 loader 函数可以有个 raw 属性,标识是按 Buffer or String 的方式导出资源内容 /** * loader 有 3 中返回值的方式 * 1、异步 * const callback = this.async * callback(err,newRource) * * 2、同步 * const callback = this.callback * callback(err,newRource) * * 3、同步 * return newRource */ function loader(source) { const asyncCallback = this.async // 异步 const syncCallback = this.callback // 内部回调(同步) const newSource = source + 'new'; // 1、asyncCallback(err, newSource) 异步 // 2、syncCallback(err, newSource) 同步 // 3、return newSource 同步 return newSource; } /** * loader.pitch 函数,起熔断作用 */ loader.pitch = function (data) { /** * loader.pitch返回非 undefined 值, 表示中断后续的loader执行,接着执行上一个loader * 例如 loader:[style-loader,css-loader,postcss-loader,less-loader] * 如果 css-loader 的 pitch 函数放回非undefined值 * 那么 就不在执行 postcss-loader,less-loader * 而是 执行 style-loader,并且把这个非 undefined 值传给style-loader */ // return '非undefined' // return undefined }; // true 表示返回 资源内容的 Buffer 流, // false 表示返回 资源内容的 字符串 loader.raw = true 3、Loader 的执行流程 在 Webpack 中 ,loader 是通过 loader-runner 来执行的, 刚开始 loader-runner 是从左到右迭代执行了所有 loader 的 pitch 方法, 执行到后一个 loader.pitch 后会读取资源文件,把资源文件内容传给最右边的 loader(loader3), 然后在从 右 到 左 逐个执行 loader 函数,这就解释了在很多资料上看到说 loader 是 从右到左执行, 其实那只是表面看到的 , 说法也不完成正确 4、Loader 的分类 Loader 从配置和使用方面大致可以分成: 前置Loader、普通的Loader、内联Loader、后置Loader 配置后的执行顺序 5、loader-runner 依赖关系讲解的时候提到可以从当前文件的 AST 中收集到当前文件的所有依赖的相对路径,然后在把 相对路径 转换成 绝对路径,再通过绝对路径去读取依赖的资源文件,其实在 Webpack 中 读取文件这事是 webpack 调用 loader-runner 做的 import { runLoaders } from "loader-runner"; runLoaders({ // 要读取的资源绝对路径 resource: "/abs/path/to/file.txt?query", // 资源配置的 loader loaders: ["/abs/path/to/loader.js?query"], // webpack 传给 loader-runner 的参数 context: { minimize: true }, // 可以自己定义开始迭代loader的方法 processResource: (loaderContext, resourcePath, callback) => { }, // 默认的文件系统 readResource: fs.readFile.bind(fs) }, function(err, result) { // result 处理后的最终代码 // 拿到转换后的源代码之后,把 它 转换成 AST ,继续后面的操作 }) 下面是 loader-runner 迭代 pitch 与 loader 函数最重要的一个方法,清楚的记录了 loader 的执行流程 function iteratePitchingLoaders(options, loaderContext, callback) { // 如果已经迭代到最后一个 loader.pitch 函数了 // 就读取资源文件 ,开始从 右到左迭代 loader 函数了 if(loaderContext.loaderIndex >= loaderContext.loaders.length){ return processResource(options, loaderContext, callback); } // 拿到当前迭代的loader var currentLoaderObject = loaderContext.loaders[loaderContext.loaderIndex]; // 如果当前迭代的loader.pitch 已经执行,就开始执行下个 loader.pitch if(currentLoaderObject.pitchExecuted) { loaderContext.loaderIndex++; return iteratePitchingLoaders(options, loaderContext, callback); } // load loader module // 开始迭代之前首先要加载当前 loader 本身 // 加载 loader 之后在去迭代 loader.pitch 或 loader.normalLoader(loader 函数) loadLoader(currentLoaderObject, function(err) { if(err) { loaderContext.cacheable(false); return callback(err); } // 拿到 loader.pitch var fn = currentLoaderObject.pitch; // 标记 loader.pitch 已经迭代过 currentLoaderObject.pitchExecuted = true; // 如果 loader 没有 pitch 函数,就继续下一个迭代 loader if(!fn) { return iteratePitchingLoaders(options, loaderContext, callback); } const {remainingRequest, previousRequest} = loaderContext // 开始迭代执行当前 loader.pitch runSyncOrAsync( fn, loaderContext, [remainingRequest,previousRequest, currentLoaderObject.data = {}], function(err) { // 迭代执行结果回调 if(err) return callback(err); // 第一个参数是 err , 这里先截取掉 var args = Array.prototype.slice.call(arguments, 1); // 获取 loader.pitch 返回不是 undefined 的值 var hasArg = args.some(function(value) { return value !== undefined; }); if(hasArg) { // 如果 loader.pitch 返回了非 undefined 的值 // loaderIndex-- 拿到上一个loader loaderContext.loaderIndex--; // 迭代执行上一个loader函数 iterateNormalLoaders(options, loaderContext, args, callback); } else { // 否则继续迭代执行下个loader.pitch iteratePitchingLoaders(options, loaderContext, callback); } } ); }); } Loader-runner : https://github.com/webpack/loader-runner

24
life.caoabout 5 years

带你徒手开Webpack实现原理(2)—— Plugin

带你徒手开Webpack实现原理(2)—— Plugin 四、Plugin 1、插件的概述 前面介绍了 依赖分析、抽象语法树、Loader ,其实单单功能方面来讲,理论上是可以基本实现打包功能。但是你要考虑基本的打包能力能不能满足现代各式各样的复杂打包需求或场景,换句话说就是可拓展性。 Webpack 就像一条生产线,要经过一系列的复杂处理流程后才能将源文件转换打包输出,如果这 么一个复杂的生产流程中给你两中选择方案: 方案一:产品从开始生产到出产这个过程两点一线,从开始就规划好或默认好,中途不能做任何更改 方案二:产品从开始生产到出产这个过程中的每一个重要的环节都暴露出一个 检修口,可以在 检修口 中根据产品自身特点进行调整生成过程中的资源分配,从而影响产品产出结果。 那么这两个方案你会选择哪种呢 ? 毫无疑问 我相信大家都会选择方案二,因为方案二更加符合实际生产需求,检修口使复杂的生产流程, 具备了更好的可拓展性,更加适用于当下或未来的复杂的生产流生产产品,满足个性化的产品生产。 当然 Webpack 也不列外,上面说的 “检修口” 映射到 webapck 中就是所谓的 “plugin”,当然我说的这个“检修口” 在 webpack 打包流程中并不非常准确(这里仅仅是为了更好的说明而已) ,因为 plugin 在 webpack 打包流程中并不仅仅是起了 “检修口” 的作用,甚至 webpack 的生命周期也是构建在 Plugin 事件流的基础之上,贯穿着 webpack 的整个生命周期 。 举几个例子: compiler.run 方法调用之后就触发了 make 事件, make 事件中调用 compilation.addEntry 函数,开启了真正的 webpack 编译流程(依赖分析),编译阶段 是 webpack 生命周期的一个重要环节。 webpack 在资源输出之前要清空上次打包的输出的资源,一面在资源输出之后,文件混乱不堪。 webpack 在资源输出之前要添加一份输出资源的清单文件,方便打包完成后用于增量发布 ... 说到这里也许你终于明白了这个 ”检修口“ 的重要性... 2、插件的原理 从上面的分析来看,插件的本质就是一个发布订阅事件 ,Webpack 预留了各种各样的事件(hook),使用者可以根据自己的需求去订阅相关事件,Webpack在打包流程中到了合适的时间节点就去触发。 3、Tapable 库 Tapable 是 Webpack 内部使用的发布订阅库,tapable 主要有 同步 与 异步两大类的订阅事件 1、熔断型Hook 根据上一个订阅事件的返回值来决定是否要继续执行下一个事件 class SyncBailHooks { constructor() { this.taps = []; } tap(name, handler) { this.taps.push({ name, handler }); } call(...args) { for (let i = 0; i < this.taps.length; i++) { let { handler } = this.taps[i]; let res = handler(...args); // 订阅事件返回非undefined值,将中断执行后续订阅的事件 if (res !== undefined) break; } } } Test code: const hook = new SyncBailHooks() /** * 订阅 */ hook.tap('zhangsan',()=>{ console.log('zhangsan') }) hook.tap('lisi',()=>{ console.log('lisi') return 'lisi' // 非 undefined }) hook.tap('wangwu',()=>{ console.log('wangwu') }) /** * 执行 */ hook.call() /** * 输出 */ // zhangsan // lisi 2、瀑布型Hook 上一个订阅事件的返回值是下一个订阅事件的入参 class SyncWaterfallHook { constructor() { this.taps = []; } tap(name, handler) { this.taps.push({ name, handler }); } call(age) { for (let i = 0; i < this.taps.length; i++) { let { handler } = this.taps[i]; // 执行订阅事件,传入 age 参数 let res = handler(age); // 如果订阅事件返回非undefined if (res !== undefined) { // 更新 age 参数 age = res; } } } } Test code: const hook = new SyncWaterfallHook(); /** * 订阅 */ hook.tap('张三', (age) => { console.log('张三 : ', age); return age + 1; }); hook.tap('李四', (age) => { console.log('李四 : ', age); return age + 1; }); hook.tap('王五', (age) => { console.log('王五 : ', age); return age + 1; }); /** * 执行 */ hook.call(20); /** * 输出 */ // 张三 : 20 // 李四 : 21 // 王五 : 22 3、异步串行 上一个订阅事件执行完毕后接着执行下一个订阅事件,执行时长是所有事件执行时长的总和 class AsyncSeriesHook { constructor() { this.taps = []; } tapAsync(name, fn) { this.taps.push({ name, fn }); } callAsync(params, finalCallback) { let i = 0; let len = this.taps.length; const done = (err, res) => { if (err) return finalCallback(err); next(++i); }; const next = (i) => { if (i === len) { finalCallback(); // 所有事件执行完毕,执行最终回调 } else { this.taps[i].fn(params, done); } }; next(i); } } Test code: const series = new AsyncSeriesHook(['age']); console.time('time'); /** * 订阅 */ series.tapAsync('zhangsan', (age, cb) => { setTimeout(cb, 1000); }); series.tapAsync('lisi', (age, cb) => { setTimeout(cb, 1000); }); series.tapAsync('wangwu', (age, cb) => { setTimeout(cb, 1000); }); /** * 执行 */ series.callAsync(30, (err, age) => { console.timeEnd('time'); }); /** * 输出 */ // time: 3.009s 4、异步并行 所有订阅事件并发执行,执行时长是最后执行完毕的订阅事件花费的时长 class AsyncParallelHook { constructor() { this.taps = []; } tapAsync(name, fn) { this.taps.push({ name, fn }); } callAsync(params, cb) { let len = this.taps.length; const done = (name) => { console.log('执行: ',name) len = len - 1; if (len === 0) { cb(); } }; this.taps.forEach(({name,fn}) => { fn(params, () => done(name)); }); } } Test code: const paralle = new AsyncParallelHook(['age']); console.time('time'); /** * 订阅 */ paralle.tapAsync('zhangsan', (age, cb) => { setTimeout(cb, 500); }); paralle.tapAsync('lisi', (age, cb) => { setTimeout(cb, 800); }); paralle.tapAsync('wangwu', (age, cb) => { setTimeout(cb, 1000); }); /** * 执行 */ paralle.callAsync(30, (err, age) => { console.timeEnd('time'); }); /** * 输出 */ // 执行: zhangsan // 执行: lisi // 执行: wangwu // time: 1.004s 4、Tapable 的 运用 class Compiler extends Tapable { constructor(context) { super(); this.hooks = { // 运行前 触发的 hook /** @type {AsyncSeriesHook<Compiler>} */ beforeRun: new AsyncSeriesHook(["compiler"]), // 运行时 触发的 hook /** @type {AsyncSeriesHook<Compiler>} */ run: new AsyncSeriesHook(["compiler"]), // 编译前的 触发的 hook /** @type {AsyncSeriesHook<CompilationParams>} */ beforeCompile: new AsyncSeriesHook(["params"]), // 编译时 触发的 hook /** @type {SyncHook<CompilationParams>} */ compile: new SyncHook(["params"]), // 真正开始编译时 触发的 hook /** @type {AsyncParallelHook<Compilation>} */ make: new AsyncParallelHook(["compilation"]), // 编译之后 触发的 hook /** @type {AsyncSeriesHook<Compilation>} */ afterCompile: new AsyncSeriesHook(["compilation"]), // 生成静态资源的时候 触发的hook /** @type {AsyncSeriesHook<Compilation>} */ emit: new AsyncSeriesHook(["compilation"]), // 静态资源生成之后 触发的hook /** @type {AsyncSeriesHook<Compilation>} */ afterEmit: new AsyncSeriesHook(["compilation"]), // 打包完成后 触发的hook /** @type {AsyncSeriesHook<Stats>} */ done: new AsyncSeriesHook(["stats"]), /** @type {SyncBailHook<string, Entry>} */ entryOption: new SyncBailHook(["context", "entry"]) ... } } } const EntryOptionPlugin = require('./plugin/EntryOptionPlugin') class WebpackOptionApply { process (options, compiler) { // 订阅 entryOption hook new EntryOptionPlugin().apply(compiler) // 触发 entryOption hook compiler.hooks.entryOption.call(options.context, options.entry) } } module.exports = WebpackOptionApply 5、plugin 的初始化 在 wepack 函数中会一段对插件的初始化代码: if (options.plugins && Array.isArray(options.plugins)) { for (const plugin of options.plugins) { if (typeof plugin === "function") { // 如果插件是一个函数,就是通过 call 调用,上下文,参数都是 compiler plugin.call(compiler, compiler); } else { // 如果插件不是一个函数,那么就要求插件必须的有一个 apply 方法 // 然后调用apply方法,并且把 compiler 传入到 apply 方法中 plugin.apply(compiler); } } } 6、plugin 的实现 class Plugin { constructor(options) { this.options = options; } apply(compiler) { // 订阅 运行时 触发的 hook compiler.hooks.run.tapAsync('run', (compiler) => { // todo something }); // 订阅 真正开始构建依赖时 触发的 hook compiler.hooks.make.tapAsync('compile', (compilation) => { // 订阅 building entry 时 触发的 hook compilation.hooks.addEntry.tap('addEntry',(entry,name)=> { console.log(entry) }) }); // 订阅 webpack 产出资源之后 触发的 hook compiler.hooks.afterEmit.tapAsync('afterEmit', (compilation) => { // todo something }); // 订阅 webpack 打包完成之后触 发的 hook compiler.hooks.done.tapAsync('done', (stats) => { // todo something }); } } module.export = plugin

14
life.caoabout 5 years

带你徒手开Webpack实现原理(3)—— Mini 型的 Webpack

带你徒手开Webpack实现原理(3)—— Mini 型的 Webpack 五、Webpack 实现 到目前为止我们已经分析了一遍 Webpack 实现的关键知识点,接下来我们就可以利用上面介绍的知识要点来实现一版 Mini 型的 Wepack。 0、总体思路预览 1、初始化阶段 const config = require('./webpack.config.js') const compiler = webpack(config) webpack.js const path = require('path') const NodeEnvirmentPlugin = require('./plugin/NodeEnviromentPlugin') const WebpackOptionApply = require('./WebpackOptionApply') const Compiler = require('./Compiler') const webpack = function (options) { // 初始化 context // process.cwd() 返回 Node.js 进程的当前工作目录 options.context = options.context || path.resolve(process.cwd()) // 初始化 compiler 实例 const compiler = new Compiler(options.context) // 指定 compiler.options compiler.options = Object.assign(compiler.options, options) // 初始化 webpack 的读写 API, 这里其实就是 fs new NodeEnvirmentPlugin().apply(compiler) // 初始化 plugin if (Array.isArray(options.plugins)) { options.plugins.forEach(plugin => plugin.apply(compiler)) } // 初始化 webpack 打包的入口,主要是订阅 make hooks new WebpackOptionApply().process(options,compiler) // 返回 compiler 实例 return compiler } module.exports = webpack 2、WebpackOptionApply.js const EntryOptionPlugin = require('./plugin/EntryOptionPlugin') class WebpackOptionApply { process (options, compiler) { // 订阅 entryOption hook new EntryOptionPlugin().apply(compiler) // 触发 entryOption hook compiler.hooks.entryOption.call(options.context, options.entry) } } module.exports = WebpackOptionApply 3、EntryOptionPlugin.js const SingleEntryPlugin = require('./SingleEntryPlugin') class EntryOptionPlugin { apply (compiler) { // 订阅 entryOption hook compiler.hooks.entryOption.tap('entryOptionPlugin', (context, entry) => { /** * 使用单入口插件订阅 make hook * context : 其实就是 path.resolve(process.cwd() * entry: 打包入口文件 * main : 默认打包入口文件产出 chunk 的 name 是 'main' */ new SingleEntryPlugin(context, entry, 'main').apply(compiler) }) } } module.exports = EntryOptionPlugin 4、SingleEntryPlugin.js class SingleEntryPlugin { constructor(context, entry, name) { this.name = name // 默认 main this.entry = entry // 打包入口文件 this.context = context } apply (compiler) { /** * 订阅 compiler.hooks.make 事件,是webpack打包的真正起点 * compilation: 每次打包都要创建的实例,它包含了打包过程生产的所有的资源 * callback : 完成entry编译的回调,预示着此次依赖分析结束 */ compiler.hooks.make.tapAsync('make', (compilation, callback) => { const { name, entry, context } = this // 开始进入正则的 构建编译 阶段 compilation.addEntry(context, entry, name, callback) }) } } module.exports = SingleEntryPlugin 2、启动构建阶段 compiler.run 函数调用之后,就进入启动构建阶段 const config = require('./webpack.config.js') const compiler = webpack(config) compiler.run(()=>{ console.log('打包完成') }) 1、Compiler.js const { Tapable, SyncHook, SyncBailHook, AsyncParallelHook, AsyncSeriesHook, } = require('tapable'); const Stats = require('./Stats'); const Compilation = require('./Compilation'); const NormalModuleFactory = require('./NormalModuleFactory'); const mkdirp = require('mkdirp'); const path = require('path'); class Compiler extends Tapable { constructor(context) { super(); this.context = context; this.options = {}; this.hooks = { // entry 入口 hook entryOption: new SyncBailHook(['context', 'entry']), // 运行之前 hook beforeRun: new AsyncSeriesHook(['compiler']), // 运行 hook run: new AsyncSeriesHook(['compiler']), // 编译之前hook beforeCompile: new AsyncSeriesHook(['params']), // 编译 hook compile: new SyncHook(['params']), // 实例化 Compilation 的 hook thisCompilation: new SyncHook(['compilation', 'params']), // 实例化 Compilation 的 hook compilation: new SyncHook(['compilation', 'params']), // 真正编译的hook make: new AsyncParallelHook(['compilation']), // 编译之后 afterCompile: new AsyncSeriesHook(['compilation']), // 发射文件 emit: new AsyncSeriesHook(['compilation']), // 编译完成 done: new AsyncSeriesHook(['stats']) }; } /** * webpack run 方法 * @param {*} finalCallback 最终的回调 */ run(finalCallback) { /** * 编译完成的回调 * @param {*} err * @param {*} compilation */ const onCompiled = (err, compilation) => { // ... // finalCallback() }; // 触发订阅的 beforeRun hook this.hooks.beforeRun.callAsync(this, (err) => { if (err) return finalCallback(err); // 触发订阅的 run hook this.hooks.run.callAsync(this, (err) => { if (err) return finalCallback(err); this.compile(onCompiled); }); }); } compile(compiledCallback) { // 初始化 Compilation 实例化需要的参数 const params = this.newCompilationParams(); // 触发 beforeCompile hook this.hooks.beforeCompile.callAsync(params, (err) => { if (err) return compiledCallback(err); // 触发 compile hook this.hooks.compile.call(params); // 实例化 Compilation 对象 // 此对象就是贯穿着一次编译的生命周期 // 里面保存了一次编译的所有资源信息 const compilation = this.newCompilation(params); // 触发 make hooks this.hooks.make.callAsync(compilation, (err) => { if (err) return compiledCallback(err); // ... }); }); } /** * 初始化 compilation 实例需要的 模块工厂 */ newCompilationParams() { return { normalModuleFactory: new NormalModuleFactory() }; } /** * 实例化 compilation 对象 */ newCompilation(params) { // 初始化 compilation const compilation = new Compilation(this); // 触发 thisCompilation hook this.hooks.thisCompilation.call(compilation, params); // 触发 compilation hook this.hooks.compilation.call(compilation, params); // 返回 compilation return compilation; } } module.exports = Compiler; 3、构建编译阶段 compiler.run 方法执行之后 , make hook 被触发意味着进入构建编译阶段 class SingleEntryPlugin { constructor(context, entry, name) { this.name = name // 默认 main this.entry = entry // 打包入口文件 this.context = context } apply (compiler) { compiler.hooks.make.tapAsync('make', (compilation, callback) => { const { name, entry, context } = this // 开始进入正则的 构建编译 阶段 compilation.addEntry(context, entry, name, callback) }) } } module.exports = SingleEntryPlugin // 触发 make hooks this.hooks.make.callAsync(compilation, (err) => { // 编译完成回调函数 if (err) return compiledCallback(err); // 编译完成后,就开启了 封装优化阶段 compilation.seal((err) => { if (err) return compiledCallback(err); // 触发编译完成后的 hook this.hooks.afterCompile.callAsync(compilation, (err) => { if (err) return compiledCallback(err); compiledCallback(null, compilation); }); }); }); Compilation.js const { Tapable, SyncHook } = require('Tapable'); const Parser = require('./Parser'); const parser = new Parser(); const path = require('path'); const fs = require('fs'); const neoAsync = require('neo-async'); const NormalModuleFactory = require('./NormalModuleFactory'); class Compilation extends Tapable { constructor(compiler) { super(); this.entries = []; // 打包入口模块,动态import模块 存储列表 this.modules = []; // 所有模块的存储 this.chunks = []; // 输出chunk this.files = []; // 输出的文件列表 this.assets = {}; // 输出的文件对象 this.compiler = compiler; this.options = compiler.options; this._moduleMaps = new Map(); this.asyncChunkCounter = 0; this.hooks = { // entry hook addEntry: new SyncHook(['entry', 'name']), // fail hook failedEntry: new SyncHook(['entry', 'name', 'error']), // success hook succeedEntry: new SyncHook(['entry', 'name', 'module']), // 封装 hook seal: new SyncHook(), // 成功hook succeedModule: new SyncHook(['module']), // 失败hook failModule: new SyncHook(['error']), // 生成 chunk 前 hook beforeChunks: new SyncHook(), // 生成 chunk 后 hook afterChunks: new SyncHook(['chunks']) }; } /** * main 模块入口 * @param {*} context 当前的目录 * @param {*} entry 模块入口 * @param {*} name 模块名称 * @param {*} completeCallback 模块编译完成的最终回调 */ addEntry(context, entry, name, completeCallback) { // 触发 add entry hook this.hooks.addEntry.call(entry, name); const buildCompleteCallback = (err, module) => { if (err) { this.hooks.failedEntry.call(entry, name, err); } else { this.hooks.succeedEntry.call(entry, name, module); } completeCallback(err); }; this._addModuleChain( { name, // 模块名称 'main' parser, // ast 解释器 context, // 解析的目录 rawReqeust: entry, // 入口资源 path resource: path.posix.join(context, entry) // 入口资源 绝对 路径 }, (module) => { this.entries.push(module); // 入口模块放入 entries }, buildCompleteCallback // 解析成功回调 ); } /** * 构建模块链 * @param {*} data 模块信息 * @param {*} entryCallback entry 模块回调 * @param {*} buildCompleteCallback build 成功总回调 */ _addModuleChain(data, entryCallback, buildCompleteCallback) { // 初始化模块工厂 const factory = new NormalModuleFactory(); // 通过工厂创建模块 // module 是 NormalModule 的实例 const module = factory.create(data); // 如果是入口模块 把模块添加到 entries 上去 entryCallback && entryCallback(module); // 收集 build 模块 this.modules.push(module); // 模块解析完之后调用 const afterBuild = (err) => { if (err) return buildCompleteCallback(err); // 如果模块有依赖 if (module.dependencies && module.dependencies.length > 0) { // 继续分析依赖 this.processModuleDependencies(module, (err) => { // 分析依赖完毕,再调完毕回调 buildCompleteCallback(err, module); }); } else { // 分析完毕回调 buildCompleteCallback(err, module); } }; // 开始构建模块 this.buildModule(module, afterBuild); } /** * 调用 模块 build 方法 , 构建模块 * @param {*} module * @param {*} afterBuild */ buildModule(module, afterBuild) { module.build(this, (err) => { if (err) { this.hooks.failModule.call(err); } else { this.hooks.succeedModule.call(module); } afterBuild(err); }); } /** * 处理当前模块的所有同步依赖 * @param {*} module 当前模块 * @param {*} callback 依赖处理完成回调 */ processModuleDependencies(module, callback) { neoAsync.forEach( module.dependencies, (dep, done) => { /** * dep 依赖的模块信息 * null 因为依赖不是入口,不需要加入到 entires 里面去 * done 模块解析完毕 */ this._addModuleChain(dep, null, done); }, (err) => { // 所有依赖分析完毕,执行回调 callback(err); } ); } } module.exports = Compilation; NormalModule.js const path = require('path'); const traverse = require('@babel/traverse').default; const types = require('@babel/types'); const generate = require('@babel/generator').default; const neoAsync = require('neo-async'); const { runLoaders } = require('loader-runner'); const fs = require('fs'); class NormalModule { constructor(data) { const { name, context, resource, parser, rawRequest, async = false // 默认不是异步模块 } = data; this.name = name; this._ast = null; this._source = ''; this.async = async; this.parser = parser; this.context = context; this.resource = resource; this.rawRequest = rawRequest; this.moduleId = './' + path.posix.relative(context, resource); this.blocks = []; this.dependencies = []; } /** * 模块分析入口 * @param {} compilation * @param {*} callback */ build(compilation, callback) { // 拿到模块依赖 const getDependencyResource = (node) => { // 模块名称 const moduleName = node.arguments[0].value; // 模块名称后缀,没有默认为 '.js' const extension = moduleName.split(path.posix.sep).pop().indexOf('.') == -1 ? '.js' : ''; // 拼接模块的绝对路径 const dependencyResource = path.posix.join( path.posix.dirname(this.resource), moduleName + extension ); // 生成模块的相对路径(模块ID) // 在webpack中由于所有的模块ID都为相对路径,所以这里要把绝对路径转换成相对路径 const dependencyModuleId = '.' + path.posix.sep + path.posix.relative(this.context, dependencyResource); return { moduleName, dependencyResource, dependencyModuleId }; }; // 把同步依赖保存到 dependencies 上 const pushDependencies = (node) => { // 获取依赖信息 const dep = getDependencyResource(node); // 添加常规依赖 this.dependencies.push({ name: this.name, // 一个 entry 下的所有依赖模块名称都是相同的 context: this.context, parser: this.parser, rawRequest: dep.moduleName, // 依赖模块名称(原始路径) moduleId: dep.dependencyModuleId, // 依赖模块 相对路径(模块ID) resource: dep.dependencyResource // 依赖模块 绝对路径 }); return dep; }; // 保存异步依赖到 blocks , // 注意每个异步依赖都是一个 chunk // 要保存到 entries 上去 const pushBlocks = (node, chunkName) => { // 获取依赖信息 const dep = getDependencyResource(node); // 添加 异步 依赖模块 this.blocks.push({ async: true, // 标明是异步 chunk parser: this.parser, name: chunkName, // chunk的模块名称 context: this.context, rawRequest: dep.moduleName, entry: dep.dependencyModuleId, // chunk入口 moduleId: dep.dependencyModuleId, // 依赖模块 相对路径(模块ID) resource: dep.dependencyResource // 依赖模块 绝对路径 }); return dep; }; // traverse CallExpression const CallExpressionFn = (nodePath) => { const node = nodePath.node; if (node.callee.name === 'require') { // 收集模块依赖 const { dependencyModuleId } = pushDependencies(node); // 修改 require 为 __webpack_require__ node.callee.name = '__webpack_require__'; // 修改 node 的 arguments node.arguments = [types.stringLiteral(dependencyModuleId)]; } else if (types.isImport(nodePath.node.callee)) { // import(/* webpackChunkName : "test"*/ './src/test.js') // 默认的代码块ID let chunkName = compilation.asyncChunkCounter++; // /* webpackChunkName : "chunkName" */ const leadingComments = node.arguments[0].leadingComments; // 如果有命名,提取 chunkName if (Array.isArray(leadingComments) && leadingComments.length > 0) { const comments = leadingComments[0].value; const regexp = /webpackChunkName:\s*['"]([^'"]+)['"]/; chunkName = comments.match(regexp)[1]; } const { dependencyModuleId } = pushBlocks(node, chunkName); // 替换 import(...) nodePath.replaceWithSourceString( `__webpack_require__.e("${chunkName}") .then(__webpack_require__.t.bind(null,"${dependencyModuleId}", 7))` ); } }; // 开始build资源 this.doBuild(compilation, (err, source) => { if (err) return callback(err); // 资源内容 this._source = source; // 获取源代码的 ast this._ast = this.parser.parser(source); // 遍历 ast traverse(this._ast, { CallExpression: CallExpressionFn }); // 通过最新的 ast 生成最新的源代码 const { code } = generate(this._ast); // 更新源代码 this._source = code; // 遍历异步依赖模块,开始 build chunk neoAsync.forEach( this.blocks, (block, done) => { compilation._addModuleChain( block, (module) => compilation.entries.push(module), done ); }, (err) => { // 所有依赖 build 完最终的回调 callback(err); } ); // let blocksLen = this.blocks.length; // this.blocks.forEach((block) => { // const { context, entry, name, async } = block; // compilation._addModuleChain(context, entry, name, async, (err) => { // blocksLen = blocksLen - 1; // if (blocksLen === 0) { // callback() // } // }); // }); }); } /** * 获取 build 资源 * @param {*} compilation * @param {*} callback */ doBuild(compilation, callback) { // 调用 getSoruce 方法,通过 loader-runner 加载资源 this.getSoruce(this.resource, compilation, (err, source) => { callback(err, source); }); } /** * 通过 loaderRuner 读取资源 * @param {*} resource * @param {*} compilation * @param {*} callback */ getSoruce(resource, compilation, callback) { // 拿到 config.js 配置 的 rulues const { module: { rules = [] } } = compilation.options; let loaders = []; // 遍历 rules 拿到所有的 配置的 loader for (let i = 0; i < rules.length; i++) { if (rules[i].test.test(resource)) { loaders = [...loaders, ...rules[i].use]; } } // 标准化loader loaders = loaders.map((loader) => require.resolve( path.posix.join(this.context, '5、hand/webpack/loader', loader) ) ); runLoaders( { loaders, // 所有的loader context: {}, // 可以传自定义的参数,后续loader中可以使用 resource: this.resource, // 资源绝对路径 readResource: fs.readFile.bind(fs) }, (err, { result }) => { callback(err, result[0]); } ); } } module.exports = NormalModule; 4、封装优化阶段 make hook 回调执行预示着 编译阶段结束,开始进入封装优化阶段,seal hook 被触发 this.hooks.make.callAsync(compilation, (err) => { if (err) return compiledCallback(err); // 触发 封装 hooks compilation.seal((err) => { if (err) return compiledCallback(err); this.hooks.afterCompile.callAsync(compilation, (err) => { if (err) return compiledCallback(err); compiledCallback(null, compilation); }); }); }); 1、compilation.js const { Tapable, SyncHook } = require('Tapable'); const path = require('path'); const fs = require('fs'); const ejs = require('ejs'); const Chunk = require('./Chunk'); /** * main 模板 */ const mainTemplate = fs.readFileSync( path.join(__dirname, 'template', 'main.ejs'), 'utf8' ); const mainRender = ejs.compile(mainTemplate); /** * chunck 模板 */ const chunkTemplate = fs.readFileSync( path.join(__dirname, 'template', 'chunk.ejs'), 'utf8' ); const chunkRender = ejs.compile(chunkTemplate); class Compilation extends Tapable { constructor(compiler) { super(); this.entries = []; // 打包入口模块,动态import模块 存储列表 this.modules = []; // 所有模块的存储 this.chunks = []; // 输出chunk this.files = []; // 输出的文件列表 this.assets = {}; // 输出的文件对象 this.compiler = compiler; this.options = compiler.options; this._moduleMaps = new Map(); this.asyncChunkCounter = 0; this.hooks = { // entry hook addEntry: new SyncHook(['entry', 'name']), // fail hook failedEntry: new SyncHook(['entry', 'name', 'error']), // success hook succeedEntry: new SyncHook(['entry', 'name', 'module']), // 封装 hook seal: new SyncHook(), // 成功hook succeedModule: new SyncHook(['module']), // 失败hook failModule: new SyncHook(['error']), // 生成 chunk 前 hook beforeChunks: new SyncHook(), // 生成 chunk 后 hook afterChunks: new SyncHook(['chunks']) }; } /** * 封装 * @param {*} callback */ seal(callback) { // 触发 hook this.hooks.seal.call(); this.hooks.beforeChunks.call(); // entries 有几个就有个 chunk // 开始遍历 entries this.entries.forEach((module) => { // 实力化一个 chunk const chunk = new Chunk(module); // 一个 chunk 下的 module 的 name 都是一样的 // 这是在 依赖分析的时候 处理的 chunk.modules = this.modules.filter((m) => m.name === chunk.name); // 保存的 chunks 中 this.chunks.push(chunk); }); // 触发 afterChunks this.hooks.afterChunks.call(this.chunks); // 创建 chunk 资源 this.createChunkAssets(); // 完毕回调 callback(); } /** * 遍历 chunks 生成 chunk 资源 */ createChunkAssets() { for (let i = 0; i < this.chunks.length; i++) { const chunk = this.chunks[i]; chunk.files = []; const file = chunk.name + '.js'; chunk.files.push(file); let source = ''; if (chunk.async) { // chunk 入口模板 source = chunkRender({ chunkName: chunk.entryModule.name, modules: chunk.modules }); } else { // 主入口、公共资源 模板 source = mainRender({ chunks: this.chunks.filter(chunk => chunk.async === true), entryModuleId: chunk.entryModule.moduleId, modules: chunk.modules }); } // 把生成的资源放入 assets this.emitAsset(file, source); } } emitAsset(file, source) { this.assets[file] = source; this.files.push(file); } } module.exports = Compilation; 2、主模板 : main.ejs (function (modules) { function webpackJsonpCallback(data) { // ..... }; var installedModules = {}; var installedChunks = { "main": 0 }; function jsonpScriptSrc(chunkId) { return __webpack_require__.p + "" + ({ <% for(let chunk of chunks){ %> "<%-chunk.entryModule.name%>":"<%-chunk.entryModule.name%>" <%}%> }[chunkId] || chunkId) + ".js" } /** * webpack 自己实现的 require 方法 * require 被替换成了 __webpack_require__ */ function __webpack_require__(moduleId) { if (installedModules[moduleId]) { return installedModules[moduleId].exports; } var module = installedModules[moduleId] = { i: moduleId, l: false, exports: {} }; modules[moduleId].call( module.exports, module, module.exports, __webpack_require__ ); module.l = true; return module.exports; } /** * 动态 import 被替换成了 __webpack_require__.e */ __webpack_require__.e = function requireEnsure(chunkId) { var promises = []; var installedChunkData = installedChunks[chunkId]; if (installedChunkData !== 0) { if (installedChunkData) { promises.push(installedChunkData[2]); } else { var promise = new Promise(function (resolve, reject) { installedChunkData = installedChunks[chunkId] = [resolve, reject]; }); promises.push(installedChunkData[2] = promise); var script = document.createElement('script'); var onScriptComplete; // .... // 给 srcipt 标签的 src 赋值 script.src = jsonpScriptSrc(chunkId); onScriptComplete = function (event) { // .... }; var timeout = setTimeout(function () { onScriptComplete({ type: 'timeout', target: script }); }, 120000); script.onerror = script.onload = onScriptComplete; document.head.appendChild(script); } } return Promise.all(promises); }; __webpack_require__.m = modules; __webpack_require__.c = installedModules; __webpack_require__.d = function (exports, name, getter) {//...}; __webpack_require__.r = function (exports) {//...}; __webpack_require__.t = function (value, mode) {//...}; __webpack_require__.n = function (module) {//...}; __webpack_require__.o = function (object, property) { // ...}; __webpack_require__.p = ''; __webpack_require__.oe = function (err) {// ...}; // ..... var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || []; var oldJsonpFunction = jsonpArray.push.bind(jsonpArray); jsonpArray.push = webpackJsonpCallback; jsonpArray = jsonpArray.slice(); for (var i = 0; i < jsonpArray.length; i++) { webpackJsonpCallback(jsonpArray[i]) }; var parentJsonpFunction = oldJsonpFunction; return __webpack_require__(__webpack_require__.s = "<%-entryModuleId%>"); }) ({ <% for(let module of modules) {%> "<%-module.moduleId%>": (function (module, exports, __webpack_require__) { <%-module._source%> }), <%} %> }); 3、chunk 模板:chunk.ejs (window["webpackJsonp"] = window["webpackJsonp"] || []).push([ ["<%-chunkName%>"], { <% for(let module of modules) {%> "<%-module.moduleId%>": (function (module, exports, __webpack_require__) { <%-module._source%> }), <%} %> }]); 5、资源输出阶段 上一个阶段已经把 chunk 资源放到了 assets 对象上,seal hook 回调函数执行 就 意味着封装阶段已经结束,接下来进入 emit 阶段,emit hook 被触发时 Compiler.js const { Tapable, SyncHook, SyncBailHook, AsyncParallelHook, AsyncSeriesHook, } = require('tapable'); const Stats = require('./Stats'); const Compilation = require('./Compilation'); const NormalModuleFactory = require('./NormalModuleFactory'); const mkdirp = require('mkdirp'); const path = require('path'); class Compiler extends Tapable { constructor(context) { super(); this.context = context; this.options = {}; this.hooks = { // entry 入口 hook entryOption: new SyncBailHook(['context', 'entry']), // 运行之前 hook beforeRun: new AsyncSeriesHook(['compiler']), // 运行 hook run: new AsyncSeriesHook(['compiler']), // 编译之前hook beforeCompile: new AsyncSeriesHook(['params']), // 编译 hook compile: new SyncHook(['params']), // 实例化 Compilation 的 hook thisCompilation: new SyncHook(['compilation', 'params']), // 实例化 Compilation 的 hook compilation: new SyncHook(['compilation', 'params']), // 真正编译的hook make: new AsyncParallelHook(['compilation']), // 编译之后 afterCompile: new AsyncSeriesHook(['compilation']), // 发射文件 emit: new AsyncSeriesHook(['compilation']), // 编译完成 done: new AsyncSeriesHook(['stats']) }; } /** * 输出资源 * @param {*} compilation * @param {*} callback */ emitAssets(compilation, callback) { // 触发 emit hooks this.hooks.emit.callAsync(compilation, (err) => { if (err) return callback(err); // 开始输出资源 mkdirp(this.options.output.path).then( (res) => { // 遍历 compilation.assets for (let file in compilation.assets) { // 得到文件名和文件内容 const source = compilation.assets[file]; // 得到输出的路径 targetPath const targetPath = path.posix.join(this.options.output.path, file); // 输出资源 this.outputFileSystem.writeFileSync(targetPath, source, 'utf8'); } callback(); }, (err) => { callback(err); } ); }); } /** * webpack run 方法 * @param {*} finalCallback 最终的回调 */ run(finalCallback) { /** * 编译完成的回调 * @param {*} err * @param {*} compilation */ const onCompiled = (err, compilation) => { if (err) return finalCallback(err); this.emitAssets(compilation, (err) => { if (err) return finalCallback(err); //stats是一 个用来描述打包后结果的对象 const stats = new Stats(compilation); this.hooks.done.callAsync(stats, (err) => { if (err) return finalCallback(err); // done表示整个流程结束了 finalCallback(null, stats); }); }); }; // 触发订阅的 beforeRun hook this.hooks.beforeRun.callAsync(this, (err) => { if (err) return finalCallback(err); // 触发订阅的 run hook this.hooks.run.callAsync(this, (err) => { if (err) return finalCallback(err); this.compile(onCompiled); }); }); } compile(compiledCallback) { // 初始化 Compilation 实例化需要的参数 const params = this.newCompilationParams(); // 触发 beforeCompile hook this.hooks.beforeCompile.callAsync(params, (err) => { if (err) return compiledCallback(err); // 触发 compile hook this.hooks.compile.call(params); // 实例化 Compilation 对象 // 此对象就是贯穿着一次编译的生命周期 // 里面保存了一次编译的所有资源信息 const compilation = this.newCompilation(params); // 触发 make hooks this.hooks.make.callAsync(compilation, (err) => { if (err) return compiledCallback(err); // 触发 封装 hooks compilation.seal((err) => { // seal hook 回调执行,意味着封装阶段结束 // 即将进入触发 emit hooks , 进入资源输出阶段 if (err) return compiledCallback(err); this.hooks.afterCompile.callAsync(compilation, (err) => { if (err) return compiledCallback(err); compiledCallback(null, compilation); }); }); }); }); } /** * 初始化 compilation 实例需要的 模块工厂 */ newCompilationParams() { return { normalModuleFactory: new NormalModuleFactory() }; } /** * 实例化 compilation 对象 */ newCompilation(params) { // 初始化 compilation const compilation = new Compilation(this); // 触发 thisCompilation hook this.hooks.thisCompilation.call(compilation, params); // 触发 compilation hook this.hooks.compilation.call(compilation, params); // 返回 compilation return compilation; } } module.exports = Compiler;

12
life.caoabout 5 years

从刚好铺满一屏的css需求来看移动端适配

从铺满一屏的css需求来看移动端适配 前言 在之前一个需求中遇到"固定元素在底部"的场景,之前也做过类似不过也没仔细考虑,看了些老项目、新的项目,在该场景的实现上不尽相同,主要css非常灵活,所以拿出来抛砖引玉大家相互探讨,以对H5移动端适配及相关属性有些学习! 场景 将一个元素自适应的固定在页面底部,如下图(红色框区域),常见政策协议、温馨提示等等, 期望效果 适配各类大小屏幕的手机,让底部元素保持距离底部合适的距离,保持在一屏内,拥有良好的用户体验 方案 准备工作 首先我们抽取简单元素为例,主要有两部分 content 和 footer ,footer 文案的长度保持固定,为了演示效果我们固定 content 高度为480px,共用样式如下 body{ min-height: 100%; } .footer{ border: 4px solid #44800f; padding: 0 40px; box-sizing: border-box; } .content{ height: 480px; /*固定内容高度*/ background: rgba(0,0,0,0.2); border: 4px solid #d9534f; } 1.使用absolute/fixed <div class="content"></div> <footer class="footer"> 温馨提示:任何我司员工或谎称我司员工的人向您收取现金、红包等均属诈骗行为,可向我司客服投诉: </footer> footer{ width: 100%; position: absolute; left: 50%; bottom: 0; transform: translateX(-50%); } 原理 脱离当前文档流,触发BFC(块格式化上下文),相对底部定位 优点 简单易理解 缺点 影响层级关系并受footer、content内容的高度的影响,在小屏幕手机会造成footer覆盖在contentfixed受bounces的影响,用户体验会欠佳 2.使用margin-bottom <div class="main"> <div class="content"></div> </div> <footer class="footer"> 温馨提示:任何我司员工或谎称我司员工的人向您收取现金、红包等均属诈骗行为,可向我司客服投诉: </footer> .main { min-height: 100%; padding-bottom: 100px; margin-bottom: -100px; } .footer { height: 100px; } 原理 保持文档流,margin-bottom改变元素的逻辑大小,使得后面元素覆盖元素自身(ps:margin属性top/lef方向上产生位移,bottom/right该方向不产生位移,发生覆盖) 优点 简单易理解,可以解决基本的适配,兼容性好 缺点 footer高度需固定,同样小屏幕手机会造成footer超出当前屏幕,形成滚动条,体验欠佳,未脱离文档流,改变盒模型 3.使用计算属性calc <div class="main"> <div class="content"></div> </div> <footer class="footer"> 温馨提示:任何我司员工或谎称我司员工的人向您收取现金、红包等均属诈骗行为,可向我司客服投诉: </footer> .main{ height: calc(100vh - 74px); /*74:footer的高度*/ } 原理 利用计算属性calc,动态撑开占位元素,将footer排在底部 优点 代码简单,兼容性强 缺点 缺点:需要手动计算,且在不同手机上footer实际高度不一样,小屏幕手机会造成footer超出当前屏幕,形成滚动条,体验欠佳 4.使用flex布局 <div class="main"> <div class="content"></div> </div> <footer> 温馨提示:任何我司员工或谎称我司员工的人向您收取现金、红包等均属诈骗行为,可向我司客服投诉: </footer> body{ display: flex; flex-direction: column; } .main{ flex: 1; } 原理 采用弹性布局,容器主轴方向改为列项目设置flex:1(等同flex: 1 1 0%),元素尺寸可以弹性变大或变小,在尺寸不足时会优先最小化内容尺寸,所以也就可以让main元素充分利用剩余空间,同时不会侵占其他元素应有的大小 优点 弹性布局,不需要考虑元素大小,代码简单,兼容性较好 缺点 在小屏幕手机上,不挤压元素的情况下会超出一屏,形成滚动条,体验欠佳 5.使用flex+margin-top <div class="content"></div> <footer> 温馨提示:任何我司员工或谎称我司员工的人向您收取现金、红包等均属诈骗行为,可向我司客服投诉: </footer> body{ display: flex; flex-direction: column; } footer{ margin-top: auto; } 原理 采用弹性布局,容器主轴方向改为列在FFC(自适应格式化上下文)中margin-top设置为auto,则平均分配垂直方向上的剩余空间。 优点 弹性布局,不需要考虑元素大小,不会形成滚动条,保持一屏, 缺点 在小屏幕手机上,content会溢出部分内容,形成重叠容易和BFC下的margin表现搞混 6.使用grid布局 <div class="content"></div> <footer class="footer"> 温馨提示:任何我司员工或谎称我司员工的人向您收取现金、红包等均属诈骗行为,可向我司客服投诉: </footer> body{ display: grid; grid-template-rows: 1fr auto; } .footer{ grid-row-start: 2; grid-row-end: 3; } 原理 网格布局GFC,1fr 表示“占用可用空间的 1 部分”,浏览器渲染网格时,它首先计算auto元素的必要大小——它们都得到了它们可以承受的最小尺寸,然后因为没有其他元素,所以仅有的1fr自动填补剩余的空白 优点 不需要在意底部元素footer的高度,代码简单,网格二维布局更加灵活方便, 缺点 在不挤压元素的情况下,小屏幕手机会根据超出屏幕会有滚动条,并兼容性较差 7.使用vw配合上面任何一种方式 以vw+方式5为例 body{ display: grid; grid-template-rows: 1fr auto; } footer{ border: 1.07vw solid #44800f; padding: 0 10.67vw; box-sizing: border-box; grid-row-start: 2; grid-row-end: 3; font-size: 4.27vw; } .content{ height: 128vw; background: rgba(0,0,0,0.2); border: 1.07vw solid #d9534f; } 原理 网格布局GFC(网格布局格式化上下文)+相对单位vw使用相对单位,让其整个页面元素均相同比例适配 优点 app移动端的大小屏幕适配比较完美,体验好,页面整体适配较好 缺点 很多老项目是px,需要改造在平板、PC等大窗口上无法完美适配 思考 通过目前方案1-6我们可以看到,如果在采用px为单位的前提下,这些方法均无法完美实现该场景,它们的本质大多数都是使content和footer之间的距离或者大小自适应来实现,无法使content和footer本身自适应,究其原因就是px为绝对单位,受设备的 DPI 和分辨率影响,所以如果基础准确适合,很多问题就迎刃而解了。 现在移动端H5的适配主流方案有rem和vw(viewpoint width),vw目前来看是下个阶段的主流,公司新建的项目也都是采用的vw,我们主要看下vw:相对单位,单纯css,只依赖视口宽度,比如手机在老年人模式下,页面样式布局也是正常的 老项目逐步vw改造 受此场景影响,我对老项目开启了vw改造,因为一些仓库模块多,所以根据当前手上业务需求逐渐的分模块进行改造,直至全部通过,顺便记录下我的一些采坑点。 插件 我们采用postcss-px-to-viewport来进行编译时的转换,建议根据自己项目需求配置以下插件 postcss-aspect-ratio-mini postcss-px-to-viewport postcss-write-svg postcss-cssnext cssnano postcss-viewport-units 然后我们安装 npm i postcss-aspect-ratio-mini postcss-px-to-viewport postcss-write-svg postcss-cssnext postcss-viewport-units cssnano --save-dev 安装成功后,我们在根目录的.postcssrc.js文件对新安装的PostCSS插件进行配置: 如果你的项目没有.postcssrc.js文件可以自行新增,如果新增配置后没有效果,尝试更改文件为postcss.config module.exports = { "plugins": { "postcss-px-to-viewport":{ viewportWidth: 375, // 视窗的宽度,对应的是我们设计稿的宽度,一般是375/750 unitPrecision: 3, // 指定`px`转换为视窗单位值的小数位数 viewportUnit: "vw", //指定需要转换成的视窗单位,建议使用vw selectorBlackList: ['.ignore', '.hairlines'],// 指定不转换为视窗单位的类,可以自定义,可以无限添加,建议定义一至两个通用的类名 minPixelValue: 1, // 小于或等于`1px`不转换为视窗单位,你也可以设置为你想要的值 include: /\/src\//, //只有匹配到的文件才会被转换 mediaQuery: false // 允许在媒体查询中转换`px` }, "postcss-viewport-units": {}, "cssnano": { preset: "advanced", autoprefixer: false, "postcss-zindex": false } } } 更加详细的配置可以参考https://github.com/evrone/postcss-px-to-viewport 注意事项 1.postcss-px-to-viewport会对内联css样式,外联css样式有效,对内嵌css样式无效 如果在安装过程中报错,一般是检查插件之间版本不兼容问题,可指定合适版本或者全部更新 3.如果在package.json中就会出现not dead,导致报错: Error: Loading PostCSS Plugin failed: Unknown browser query dead,解决方法是:删除 "not dead"掉就可以 4.对.js文件导出的样式无效 5.现在的postcss-px-to-viewport插件npm包最新是1.1.1版本,关键是它的文档上面写的某些功能字段(比如include)最新代码是还没发布到npm上去的,就是说你安装最新npm包是不包括该功能的,并且不报错,如果想用最新功能的代码,可以直接用git仓库的代码 npm i https://github.com/evrone/postcss-px-to-viewport --save-dev 指定文件路径时,访问该文件路由时,只会转换指定的文件,该文件引入的其他文件里面的样式不会转换

13
life.caoabout 5 years

潜聊JavaScript 策略模式

潜聊JavaScript 策略模式 1、什么是策略模式 1.1、定义 定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。 1.2、目的 策略模式的目的是将算法的使用和算法的实现分离开来。 1.3、例子 在现实中,我们可以有多种途径到达同一个目的地。比如我们要去某个地方旅游,可以根据具体的实际情况来选择出行的线路。 如果没有时间但是不在乎钱,可以选择坐飞机。 如果没有钱,可以选择坐大巴或者火车。 如果再穷一点,可以选择骑自行车。 在程序设计中,我们实现某一个功能也有多种方案可以选择。比如一个压缩文件的程序,既可以选择 zip 算法,也可以选择 gzip 算法。这些算法灵活多样,而且可以随意互相替换。 2、使用策略模式 策略模式有着广泛的应用。这里我们用司机会员和表单校验作为示例。 2.1、司机会员 假如会员分为超级会员、普通会员和非会员。司机完成一个订单,超级会员可获得订单价格的 4 倍积分,普通会员可获得 2 倍积分,非会员可获得 1 倍积分。 2.1.1、最初的代码实现 我们编写一个名为 getPoint 的函数来获取司机对应的积分,显然,我们需要传入两个参数,司机的会员等级和订单的价格。代码如下: const getPoint = (level, price) => { if (level === "superVip") { return price * 4; } if (level === "vip") { return price * 2; } if (level === "notVip") { return price; } }; getPoint 函数非常简单,但也存在着比较明显的缺点: 函数比较庞大,包含很多 if 语句,这些语句需要覆盖所有的逻辑分支 函数缺乏弹性,如果增加会员等级或者修改积分倍数,都需要在函数内部实现 复用性差 因此,我们对 getPoint 函数做一次改进,使用组合函数来重构代码。 我们把每个会员等级的算法封装到一个小函数里面,这些小函数有着良好的命名,可以一眼就知道它对应哪种算法。这些函数也可以被复用在程序的其他地方。代码如下: const superVipPoint = (price) => price * 4; const vipPoint = (price) => price * 2; const notVipPoint = (price) => price; const getPoint = (level, price) => { if (level === "superVip") { return superVipPoint(price); } if (level === "vip") { return vipPoint(price); } if (level === "notVip") { return notVipPoint(price); } }; 这里 getPoint 函数得到一定的改善,但是非常有限,因为我们依然没有解决最重要的问题:getPoint 函数有可能越来越庞大,而且在系统变化时缺乏一定的弹性。所以,下面我们考虑使用策略模式重构代码。 2.1.2、使用策略模式重构代码 一个基于策略模式的程序至少由两部分组成。第一部分是一组策略类,策略类封装具体的算法,负责具体的计算过程。第二部分是环境类 Context,Context 接受客户请求,然后把请求委托给某一策略类。代码如下: const strategies = { superVip: (price) => price * 4, vip: (price) => price * 2, notVip: (price) => price, }; const getPoint = (level, price) => { return strategies[level](price); }; 我们再来回顾一下策略模式的思想:定义一系列的算法,把这些算法封装在策略类里面,在客户对 Context 发起请求时,Context 总是把请求委托给策略对象中的某一个进行计算。 上面例子中,我们编写了一个 strategies 对象,strategies 中封装了司机各个会员等级获取积分的具体实现方法,然后使用 getPoint 函数充当 Context 的角色,getPoint 本身没有计算能力,在接受客户请求时,会把请求委托给 strategies 中的某一方法计算。 经过改造之后可以看出,我们消除了原程序中大片的条件分支语句,代码也变得更加简洁。 2.2、表单校验 在我们平常的项目开发中,经常有注册、登录、修改信息等功能,这些功能的实现都离不开提交表单。假如我们编写一个注册的页面,在提交信息时有以下几条校验: 身份证号不能为空 密码长度必须大于 6 位 手机号码要符合格式 2.2.1、最初的代码实现 我们编写一个 submit 函数,在点击提交时触发。 const form = document.getElementById("form"); const submit = () => { if (form.idCardNum.value === "") { alert("身份证号不能为空"); return false; } if (form.password.value.length < 6) { alert("密码长度不能少于6位"); return false; } if (!/(^1[3||5|8][0-9]{9})/.test(form.phoneNumber.value)) { alert("手机号码格式不正确"); return false; } }; 可以看出,这是一种常见的实现方式,但它的缺点跟司机会员最初实现的代码一模一样。 submit 函数比较庞大,包含很多 if 语句,这些语句需要覆盖所有的逻辑分支。 函数缺乏弹性,如果想新增一条新的校验规则,或者想改变其中一条规则,都需要在函数内部实现。 复用性差。如果程序中还有另一个表单,这个表单也需要有类似的校验,我们可能会直接 copy 这个校验逻辑,使得可维护性越来越差。 下面我们使用策略模式重构代码。 2.2.2、使用策略模式重构代码 第一步,我们将校验逻辑封装成策略对象 const strategies = { isNonEmpty: (value, errorMsg) => { if (value === "") { return errorMsg; } }, minLength: (value, length, errorMsg) => { if (value.length < length) { return errorMsg; } }, isPhoneNum: (value, errorMsg) => { if ( !/^1(00|3\d|4[014-9]|5[0-35-9]|6[25-7]|7[0-8]|8\d|9[0-35-9])\d{8}$/.test( value ) ) { return errorMsg; } }, }; 接下来我们使用 Validator 作为 Context,Validator 接受客户请求,并把请求委托给 strategies 对象。 const Validator = function () { this.cache = []; }; // 添加校验规则 Validator.prototype.add = function (value, rules) { for (let i = 0, rule; (rule = rules[i++]); ) { ((rule) => { let strategyAry = rule.strategy.split(":"); let errorMsg = rule.errorMsg; this.cache.push(() => { let strategy = strategyAry.shift(); strategyAry.unshift(value); strategyAry.push(errorMsg); return strategies[strategy].apply(null, strategyAry); }); })(rule); } }; // 开始校验 Validator.prototype.start = function () { for (let i = 0, validatorFunc; (validatorFunc = this.cache[i++]); ) { let msg = validatorFunc(); if (msg) { return msg; } } }; Validator 中封装两个方法 add 和 start。 add 方法接受两个参数,第一个参数为需要校验的 input 输入框,第二个参数为该 input 输入框对应的校验规则,这里我们使用数组的方式,以增加函数的弹性。 validator.add(form.password.value, [ { strategy: "minLength:6", errorMsg: "密码长度不能少于6位", }, ]); strategy 为一个以冒号隔开的字符串,冒号前面代码对应的校验规则,冒号后面校验过程中所需要的一些参数。如果这个字符串中不包含冒号,则说明校验过程中不需要额外的参数。 errorMsg 代表校验失败时返回的错误信息。 start 方法作为启动开关,在开始校验时触发。如果 start 返回一个确切的字符串,则说明校验没有通过。 使用策略模式重构代码之后,我们只需要通过“配置”的方式实现一个表单的校验,这些校验规则可以复用在程序的任何地方。在修改某个校验规则的时候,我们只需要改动少量的代码。比如想将密码校验改为不能为空,这样的修改也是轻而易举的。代码如下: validator.add(form.password.value, [ { strategy: "minLength:6", errorMsg: "密码长度不能少于6位", }, ]); // 改成 validator.add(form.password.value, [ { strategy: "isNonEmpty", errorMsg: "密码不能为空", }, ]); 又或者想对一个输入框添加多条校验规则时,我们只需要在对应的校验数组中添加多一条规则。比如: validator.add(form.password.value, [ { strategy: "isNonEmpty", errorMsg: "密码不能为空", }, { strategy: "minLength:6", errorMsg: "密码长度不能少于6位", }, ]); 以下是代码的完整实现: const strategies = { isNonEmpty: (value, errorMsg) => { if (value === "") { return errorMsg; } }, minLength: (value, length, errorMsg) => { if (value.length < length) { return errorMsg; } }, isPhoneNum: (value, errorMsg) => { if ( !/^1(00|3\d|4[014-9]|5[0-35-9]|6[25-7]|7[0-8]|8\d|9[0-35-9])\d{8}$/.test( value ) ) { return errorMsg; } }, }; const Validator = function () { this.cache = []; }; // 添加校验规则 Validator.prototype.add = function (value, rules) { for (let i = 0, rule; (rule = rules[i++]); ) { ((rule) => { let strategyAry = rule.strategy.split(":"); let errorMsg = rule.errorMsg; this.cache.push(() => { let strategy = strategyAry.shift(); strategyAry.unshift(value); strategyAry.push(errorMsg); return strategies[strategy].apply(null, strategyAry); }); })(rule); } }; // 开始校验 Validator.prototype.start = function () { for (let i = 0, validatorFunc; (validatorFunc = this.cache[i++]); ) { let msg = validatorFunc(); if (msg) { return msg; } } }; const validatorFunc = () => { const validator = new Validator(); validator.add(form.idCardNum.value, [ { strategy: "isNonEmpty", errorMsg: "身份证号不能为空", }, ]); validator.add(form.password.value, [ { strategy: "minLength:6", errorMsg: "密码长度不能少于6位", }, ]); validator.add(form.phoneNum.value, [ { strategy: "isPhoneNum", errorMsg: "手机号码格式不对", }, ]); const errorMsg = validator.start(); return errorMsg; }; const submit = () => { const errorMsg = validatorFunc(); if (errorMsg) { alert(errorMsg); return false; } }; 3、总结 3.1、优点 策略模式利用组合、委托等技术和思想,可以有效避免多重条件选择语句。 策略模式将算法封装在独立的策略对象中,使它们易于切换和扩展。 策略模式中的算法也可以复用在程序的其他地方中,从而避免许多重复的复制粘贴工作。 3.2、缺点 在程序增加了许多策略类或者策略对象。 必须要了解策略对象中的所有方法,了解各个具体方法之间的不同点,这样才能选择一个合适的策略方法。就像开头说到的例子,需要了解选择飞机、火车或者自行车等方案的细节,我们才能选择合适的出行方案。 4、参考 [1] 曾探. JavaScript设计模式与开发实践[M]. 北京:人民邮电出版社,2015.

8
life.caoover 5 years

JavaScript中的 发布-订阅模式

JavaScript中的 发布-订阅模式 发布-订阅模式,看似陌生,其实不然。工作中经常会用到,例如 Node.js EventEmitter 中的 on 和 emit 方法;Vue 中的 $on 和 $emit 方法;Dom 注册事件 document.addEventListener('click', (event) => {})。他们都使用了发布-订阅模式,让开发变得更加高效方便。 一、 什么是发布-订阅模式 1. 定义 发布-订阅模式其实是一种对象间一对多的依赖关系,当一个对象的状态发送改变时,所有依赖于它的对象都将得到状态改变的通知。 订阅者(Subscriber)把自己想订阅的事件注册(Subscribe)到调度中心(Event Channel),当发布者(Publisher)发布该事件(Publish Event)到调度中心,也就是该事件触发时,由调度中心统一调度(Fire Event)订阅者注册到调度中心的处理代码。 2. 例子 比如我们很喜欢看某个公众号号的文章,但是我们不知道什么时候发布新文章,要不定时的去翻阅;这时候,我们可以关注该公众号,当有文章推送时,会有消息及时通知我们文章更新了。 上面一个看似简单的操作,其实是一个典型的发布订阅模式,公众号属于发布者,用户属于订阅者;用户将订阅公众号的事件注册到调度中心,公众号作为发布者,当有新文章发布时,公众号发布该事件到调度中心,调度中心会及时发消息告知用户。 二、 如何实现发布-订阅模式? 1. 实现思路 创建一个对象 在该对象上创建一个缓存列表(调度中心) on 方法用来把函数 fn 都加到缓存列表中(订阅者注册事件到调度中心) emit 方法取到 arguments 里第一个当做 event,根据 event 值去执行对应缓存列表中的函数(发布者发布事件到调度中心,调度中心处理代码) off 方法可以根据 event 值取消订阅(取消订阅) once 方法只监听一次,调用完毕后删除缓存函数(订阅一次) 2. 基础版 我们来看个简单的 demo,实现了 on 和 emit 方法,代码中有详细注释。 // 公众号对象 let eventEmitter = {}; // 缓存列表,存放 event 及 fn eventEmitter.list = {}; // 订阅 eventEmitter.on = function (event, fn) { let _this = this; // 如果对象中没有对应的 event 值,也就是说明没有订阅过,就给 event 创建个缓存列表 // 如有对象中有相应的 event 值,把 fn 添加到对应 event 的缓存列表里 (_this.list[event] || (_this.list[event] = [])).push(fn); return _this; }; // 发布 eventEmitter.emit = function () { let _this = this; // 第一个参数是对应的 event 值,直接用数组的 shift 方法取出 let event = [].shift.call(arguments), fns = [..._this.list[event]]; // 如果缓存列表里没有 fn 就返回 false if (!fns || fns.length === 0) { return false; } // 遍历 event 值对应的缓存列表,依次执行 fn fns.forEach(fn => { fn.apply(_this, arguments); }); return _this; }; function user1 (content) { console.log('用户1订阅了:', content); }; function user2 (content) { console.log('用户2订阅了:', content); }; // 订阅 eventEmitter.on('article', user1); eventEmitter.on('article', user2); // 发布 eventEmitter.emit('article', 'Javascript 发布-订阅模式'); /* 用户1订阅了: Javascript 发布-订阅模式 用户2订阅了: Javascript 发布-订阅模式 */ 3. 加强版 这一版中我们补充了一下 once 和 off 方法。 let eventEmitter = { // 缓存列表 list: {}, // 订阅 on (event, fn) { let _this = this; // 如果对象中没有对应的 event 值,也就是说明没有订阅过,就给 event 创建个缓存列表 // 如有对象中有相应的 event 值,把 fn 添加到对应 event 的缓存列表里 (_this.list[event] || (_this.list[event] = [])).push(fn); return _this; }, // 监听一次 once (event, fn) { // 先绑定,调用后删除 let _this = this; function on () { _this.off(event, on); fn.apply(_this, arguments); } on.fn = fn; _this.on(event, on); return _this; }, // 取消订阅 off (event, fn) { let _this = this; let fns = _this.list[event]; // 如果缓存列表中没有相应的 fn,返回false if (!fns) return false; if (!fn) { // 如果没有传 fn 的话,就会将 event 值对应缓存列表中的 fn 都清空 fns && (fns.length = 0); } else { // 若有 fn,遍历缓存列表,看看传入的 fn 与哪个函数相同,如果相同就直接从缓存列表中删掉即可 let cb; for (let i = 0, cbLen = fns.length; i < cbLen; i++) { cb = fns[i]; if (cb === fn || cb.fn === fn) { fns.splice(i, 1); break } } } return _this; }, // 发布 emit () { let _this = this; // 第一个参数是对应的 event 值,直接用数组的 shift 方法取出 let event = [].shift.call(arguments), fns = [..._this.list[event]]; // 如果缓存列表里没有 fn 就返回 false if (!fns || fns.length === 0) { return false; } // 遍历 event 值对应的缓存列表,依次执行 fn fns.forEach(fn => { fn.apply(_this, arguments); }); return _this; } }; function user1 (content) { console.log('用户1订阅了:', content); } function user2 (content) { console.log('用户2订阅了:', content); } function user3 (content) { console.log('用户3订阅了:', content); } function user4 (content) { console.log('用户4订阅了:', content); } // 订阅 eventEmitter.on('article1', user1); eventEmitter.on('article1', user2); eventEmitter.on('article1', user3); // 取消user2方法的订阅 eventEmitter.off('article1', user2); eventEmitter.once('article2', user4) // 发布 eventEmitter.emit('article1', 'Javascript 发布-订阅模式'); eventEmitter.emit('article1', 'Javascript 发布-订阅模式'); eventEmitter.emit('article2', 'Javascript 观察者模式'); eventEmitter.emit('article2', 'Javascript 观察者模式'); // eventEmitter.on('article1', user3).emit('article1', 'test111'); /* 用户1订阅了: Javascript 发布-订阅模式 用户3订阅了: Javascript 发布-订阅模式 用户1订阅了: Javascript 发布-订阅模式 用户3订阅了: Javascript 发布-订阅模式 用户4订阅了: Javascript 观察者模式 */ 三、 Vue 中 发布-订阅的实现 有了发布-订阅模式的知识后,我们来看下 Vue 中怎么实现 $on 和 $emit 的方法,实现思路大体相同,如上第二点中的第一条:Vue 中实现的方法支持订阅数组事件。直接看源码: function eventsMixin (Vue) { var hookRE = /^hook:/; Vue.prototype.$on = function (event, fn) { var this$1 = this; var vm = this; // event 为数组时,循环执行 $on if (Array.isArray(event)) { for (var i = 0, l = event.length; i < l; i++) { this$1.$on(event[i], fn); } } else { (vm._events[event] || (vm._events[event] = [])).push(fn); // optimize hook:event cost by using a boolean flag marked at registration // instead of a hash lookup if (hookRE.test(event)) { vm._hasHookEvent = true; } } return vm }; Vue.prototype.$once = function (event, fn) { var vm = this; // 先绑定,后删除 function on () { vm.$off(event, on); fn.apply(vm, arguments); } on.fn = fn; vm.$on(event, on); return vm }; Vue.prototype.$off = function (event, fn) { var this$1 = this; var vm = this; // all,若没有传参数,清空所有订阅 if (!arguments.length) { vm._events = Object.create(null); return vm } // array of events,events 为数组时,循环执行 $off if (Array.isArray(event)) { for (var i = 0, l = event.length; i < l; i++) { this$1.$off(event[i], fn); } return vm } // specific event var cbs = vm._events[event]; if (!cbs) { // 没有 cbs 直接 return this return vm } if (!fn) { // 若没有 handler,清空 event 对应的缓存列表 vm._events[event] = null; return vm } if (fn) { // specific handler,删除相应的 handler var cb; var i$1 = cbs.length; while (i$1--) { cb = cbs[i$1]; if (cb === fn || cb.fn === fn) { cbs.splice(i$1, 1); break } } } return vm }; Vue.prototype.$emit = function (event) { var vm = this; { // 传入的 event 区分大小写,若不一致,有提示 var lowerCaseEvent = event.toLowerCase(); if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) { tip( "Event \"" + lowerCaseEvent + "\" is emitted in component " + (formatComponentName(vm)) + " but the handler is registered for \"" + event + "\". " + "Note that HTML attributes are case-insensitive and you cannot use " + "v-on to listen to camelCase events when using in-DOM templates. " + "You should probably use \"" + (hyphenate(event)) + "\" instead of \"" + event + "\"." ); } } var cbs = vm._events[event]; if (cbs) { cbs = cbs.length > 1 ? toArray(cbs) : cbs; // 只取回调函数,不取 event var args = toArray(arguments, 1); for (var i = 0, l = cbs.length; i < l; i++) { try { cbs[i].apply(vm, args); } catch (e) { handleError(e, vm, ("event handler for \"" + event + "\"")); } } } return vm }; } /*** * Convert an Array-like object to a real Array. */ function toArray (list, start) { start = start || 0; var i = list.length - start; var ret = new Array(i); while (i--) { ret[i] = list[i + start]; } return ret } 四、 发布-订阅模式总结 1. 优点 对象之间解耦 异步编程中,可以更松耦合的代码编写 2. 缺点 创建订阅者本身要消耗一定的时间和内存 虽然可以弱化对象之间的联系,多个发布者和订阅者嵌套一起的时候,程序难以跟踪维护 五、 观察者模式 定义 一群观察者(Observers)观察监听某个被观察对象(Subject),当有关状态发生变化时,Subject 会通知这一系列 Observers 触发更新。 可以理解为:一个班里的学生们都在听老师讲课,当老师布置任务时,会通知学生们都去执行。 简单实现 function Subject(){ this.observers = []; } Subject.prototype = { // 添加观察者 add: function(observer) { this.observers.push( observer ); }, // 移除观察者 remove: function(observer) { var observers = this.observers; var len = observers.length; for(var i=0; i<len; i++){ if(observers[i] === observer) { observers.splice(i, 1); } } }, // 通知观察者 notify: function(){ var observers = this.observers; var len = observers.length; for(var i=0; i<len; i++){ observers[i].update(); } } } //观察者 function Observer(name) { this.name = name; } Observer.prototype = { //观察者监听到变化后要处理的逻辑 update: function(){ console.log('被通知了---我是观察者:', this.name); } } // 使用示例: var subject = new Subject(); var fdd = new Observer('fdd'); var fxx = new Observer('fxx'); subject.add(fdd); subject.add(fxx); subject.notify(); // 最终输出结果: // 被通知了---我是观察者: fdd // 被通知了---我是观察者: fxx 六、 扩展(发布-订阅模式与观察者模式的区别) 很多地方都说发布-订阅模式是观察者模式的别名,但是他们真的一样吗?是不一样的。 这两个模式都是为了维护一系列观察者,当被观察者状态发生变更时,通知这一系列观察者去进行相应更新;然而也有一些区别,那就是发布订阅模式在发布者与订阅者之间多了一个消息管理器,使得发布者与订阅者解耦。如下图所示 发布-订阅模式 订阅者(Subscriber)把自己想订阅的事件注册(Subscribe)到调度中心(Event Channel),当发布者(Publisher)发布该事件(Publish Event)到调度中心,也就是该事件触发时,由调度中心统一调度(Fire Event)订阅者注册到调度中心的处理代码。 观察者模式 观察者(Observer)直接订阅(Subscribe)主题(Subject),而当主题被激活的时候,会触发(Fire Event)观察者里的事件。如下图 差异 在观察者模式中,观察者是知道 Subject 的,Subject 一直保持对观察者进行记录。然而,在发布订阅模式中,发布者和订阅者不知道对方的存在。它们只有通过消息代理进行通信。 在发布订阅模式中,组件是松散耦合的,正好和观察者模式相反。 观察者模式大多数时候是同步的,比如当事件触发,Subject 就会去调用观察者的方法。而发布-订阅模式大多数时候是异步的(使用消息队列)。 观察者模式需要在单个应用程序地址空间中实现,而发布-订阅更像交叉应用模式。 数据的双向绑定 比如比较当下热门 vue 框架,里面不少地方都涉及到了观察者模式,比如: 利用 Object.defineProperty() 对数据进行劫持,设置一个监听器 Observer,用来监听所有属性,如果属性上发上变化了,就需要告诉订阅者 Watcher 去更新数据,最后指令解析器 Compile 解析对应的指令,进而会执行对应的更新函数,从而更新视图,实现了双向绑定~ 子组件与父组件通信 Vue 中我们通过 props 完成父组件向子组件传递数据,子组件与父组件通信我们通过自定义事件即 $on,$emit来实现,其实也就是通过 $emit 来发布消息,并对订阅者 $on 做统一处理 ~ 七、大显身手 自定义数据的双向绑定 上面说到,vue 双向绑定是数据劫持和发布订阅做实现的,现在我们借助这种思想,自己来实现一个简单的数据的双向绑定,首先是要有页面结构 <div id="app"> <h3>数据的双向绑定</h3> <div class="cell"> <div class="text" v-text="myText"></div> <input class="input" type="text" v-model="myText" > </div> </div> 相信你已经知道了,我们要做到就是 input 标签的输入,通过 v-text 绑定到类名为 text 的 div 标签上 这里我们需要创建一个类,这里就叫做 myVue 吧。 class myVue{ constructor (options){ // 传入的配置参数 this.options = options; // 根元素 this.$el = document.querySelector(options.el); // 数据域 this.$data = options.data; // 保存数据model与view相关的指令,当model改变时,我们会触发其中的指令类更新,保证view也能实时更新 this._directives = {}; // 数据劫持,重新定义数据的 set 和 get 方法 this._obverse(this.$data); // 解析器,解析模板指令,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图 this._compile(this.$el); } } 这里我们定义了 myVue 构造函数,并在构造方法中进行了一些初始化操作,上面做了注释,这里我就不在赘述,主要来看里面关键的两个方法 _obverse 和 _compile。 首先是 _observe 方法,他的作用就是处理传入的 data ,并重新定义 data 的 set 和 get 方法,保证我们在 data 发生变化的时候能跟踪到,并发布通知,主要用到了 Object.defineProperty() 这个方法,对这个方法还不太熟悉的小伙伴们,请猛点这里 _observe 实现 //_obverse 函数,对data进行处理,重写data的set和get函数 _obverse(data){ let val ; //遍历数据 for( let key in data ){ // 判断是不是属于自己本身的属性 if( data.hasOwnProperty(key) ){ this._directives[key] = []; } val = data[key]; //递归遍历 if ( typeof val === 'object' ) { //递归遍历 this._obverse(val); } // 初始当前数据的执行队列 let _dir = this._directives[key]; //重新定义数据的 get 和 set 方法 Object.defineProperty(this.$data,key,{ enumerable: true, configurable: true, get: function () { return val; }, set: function (newVal) { if ( val !== newVal ) { val = newVal; // 当 myText 改变时,触发 _directives 中的绑定的Watcher类的更新 _dir.forEach(function (item) { //调用自身指令的更新操作 item._update(); }) } } }) } } 上面的代码也很简单,注释也都很清楚,不过有个问题就是,我在递归遍历数据的时候,偷了个小懒,这里我只涉及到了一些简单的数据结构,复杂的例如循环引用的这种我没有考虑进入 接着我们来看看 _compile 这个方法,它实际上是一个解析器,其功能就是解析模板指令,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,就收到通知,然后去更新视图变化,具体实现如下: _compile 实现 _compile(el){ //子元素 let nodes = el.children; for( let i = 0 ; i < nodes.length ; i++ ){ let node = nodes[i]; // 递归对所有元素进行遍历,并进行处理 if( node.children.length ){ this._compile(node); } //如果有 v-text 指令 , 监控 node的值 并及时更新 if( node.hasAttribute('v-text')){ let attrValue = node.getAttribute('v-text'); //将指令对应的执行方法放入指令集 this._directives[attrValue].push(new Watcher('text',node,this,attrValue,'innerHTML')) } //如果有 v-model属性,并且元素是INPUT或者TEXTAREA,我们监听它的input事件 if( node.hasAttribute('v-model') && ( node.tagName === 'INPUT' || node.tagName === 'TEXTAREA')){ let _this = this; //添加input时间 node.addEventListener('input',(function(){ let attrValue = node.getAttribute('v-model'); //初始化赋值 _this._directives[attrValue].push(new Watcher('input',node,_this,attrValue,'value')); return function () { //后面每次都会更新 _this.$data[attrValue] = node.value; } })()) } } } 上面的代码也很清晰,我们从根元素 #app 开始递归遍历每个节点,并判断每个节点是否有对应的指令,这里我们只针对 v-text 和 v-model,我们对 v-text 进行了一次 new Watcher(),并把它放到了 myText 的指令集里面,对 v-model 也进行了解析,对其所在的 input 绑定了 input 事件,并将其通过 new Watcher() 与 myText 关联起来,那么我们就应该来看看这个 Watcher 到底是什么? Watcher 其实就是订阅者,是 _observer 和 _compile 之间通信的桥梁用来绑定更新函数,实现对 DOM 元素的更新 Warcher 实现 class Watcher{ /* * name 指令名称,例如文本节点,该值设为"text" * el 指令对应的DOM元素 * vm 指令所属myVue实例 * exp 指令对应的值,本例如"myText" * attr 绑定的属性值,本例为"innerHTML" * */ constructor (name, el, vm, exp, attr){ this.name = name; this.el = el; this.vm = vm; this.exp = exp; this.attr = attr; //更新操作 this._update(); } _update(){ this.el[this.attr] = this.vm.$data[this.exp]; } } 每次创建 Watcher 的实例,都会传入相应的参数,也会进行一次 _update 操作,上述的 _compile 中,我们创建了两个 Watcher 实例,不过这两个对应的 _update 操作不同而已,对于 div.text 的操作其实相当于 div.innerHTML=h3.innerHTML = this.data.myText , 对于 input 相当于 input.value=this.data.myText , 这样每次数据 set 的时候,我们会触发两个 _update 操作,分别更新 div 和 input 中的内容 测试初始化一下 //创建vue实例 const app = new myVue({ el : '#app' , data : { myText : 'hello world' } }) 接着,上图 这样就顺利的实现了一个简单的双向绑定!

114
life.caoover 5 years

JSX、Template如何被解析的?

JSX、Template如何被解析的? 最近正处于Vue转写React的过程中,对于React中如何转译JSX比较好奇,所以就有了下面的对比分析文章。 JSX 下面这段简单的JSX代码,在项目中配置好.babelrc后,再执行后 npx babel index.jsx -w -o index.js后就会转译成常见的js代码。 // .babelrc { "presets": ["@babel/preset-react"] } // jsx start... function formatName(user) { return user.firstName + ' ' + user.lastName; } let user = { firstName: 'Harper', lastName: 'Perez' }; let code = ( <h1> Hello, {formatName(user)}! </h1> ); // transformed js... function formatName(user) { return user.firstName + ' ' + user.lastName; } let user = { firstName: 'Harper', lastName: 'Perez' }; let code = /*#__PURE__*/React.createElement("h1", null, "Hello, ", formatName(user), "!"); 从Babel官网中,我们得知@babel/preset-react预设包含了 @babel/plugin-syntax-jsx、@babel/plugin-transform-react-jsx、@babel/plugin-transform-react-display-name三个插件,其中,**@babel/plugin-syntax-jsx给解析参数parserOpts中增加了plugins: ['jsx'],使Babel在parse过程中对JSX语法进行识别;@babel/plugin-transform-react-jsx**主要是对初步生成好的AST进行transform,比如将‘(hello,{formatName(user)}!)’生成的AST转换成'React.createElement(...codes...)'相应的AST。接下来会逐步介绍其中的过程。 如上图所示,这段在@babel/core/lib/transformation/index.js的run函数 展示了这次转换的主要流程: function* run(config, code, ast) { // parse解析 // (0, _normalizeOpts.default)(config) 这里会得知parserOpts解析参数,增加了plugins:['jsx'] const file = yield* (0, _normalizeFile.default)(config.passes, (0, _normalizeOpts.default)(config), code, ast); const opts = file.opts; // transform转换 try { yield* transformFile(file, config.passes); } catch (e) { } let outputCode, outputMap; // generate生成最终代码的过程 try { if (opts.code !== false) { ({ outputCode, outputMap } = (0, _generate.default)(config.passes, file)); } } catch (e) { } return { metadata: file.metadata, options: opts, ast: opts.ast === true ? file.ast : null, code: outputCode === undefined ? null : outputCode, map: outputMap === undefined ? null : outputMap, sourceType: file.ast.program.sourceType }; } @babel/plugin-transform-react-jsx 这个插件主要是在create-plugin.js文件中生成的。利用Babel插件机制,在遍历每个AST节点的时候,都会访问visitor函数,所以在该插件中,注册特定AST节点的enter、exit处理函数。 (流程图) { name, inherits: _pluginSyntaxJsx.default, visitor: { Program: { enter(path, state) { // file对象是什么呢?请看下图 const { file } = state; // runtime---> 'classic' let runtime = RUNTIME_DEFAULT; let source = IMPORT_SOURCE_DEFAULT; // React.createElement let pragma = PRAGMA_DEFAULT; let pragmaFrag = PRAGMA_FRAG_DEFAULT; let sourceSet = !!options.importSource; let pragmaSet = !!options.pragma; let pragmaFragSet = !!options.pragmaFrag; set(state, "runtime", runtime); if (runtime === "classic") { const createElement = toMemberExpression(pragma); const fragment = toMemberExpression(pragmaFrag); set(state, "id/createElement", () => _core.types.cloneNode(createElement)); set(state, "id/fragment", () => _core.types.cloneNode(fragment)); set(state, "defaultPure", pragma === DEFAULT.pragma); } } }, // ... } } 在看上述代码之前,先来了解一下Babel解析后的AST,下图是一个简单的变量声明: 接下来,再看看示例的代码块被Babel转化出来的AST节点,最外层是File对象,主要关注点在ast属性值里的program对象,这里描述了节点类型、代码所在的起始终止的位置、注释、以及body内的子节点等内容。 function formatName(user) { return user.firstName + ' ' + user.lastName; } 上述代码片段对应的就是以下类型为FunctionDeclaration函数声明的AST描述信息: let user = { firstName: 'Harper', lastName: 'Perez' }; 接下来是比较简单的变量声明:Identifier--->标识符-->'user';'user'有两个属性properties,对应下面的properties节点: let code = ( <h1> Hello, {formatName(user)}! </h1> ); 下图是对JSX语法的解析:在该Node节点中,id所描述的是'code',用Identifier描述符来表示;init节点表明初始化,包含children子节点、loc位置、openingElement开元素、closingElement闭合元素、leadingComments前置注释、innerComments内部注释、trailingComments后置注释等描述信息。 以下是对于children、openingElement的详细内容,可以看到children子节点内包括JSXText、JSXExpressionContainer的节点信息。 JSXText的节点信息,描述起来比较简单,关注value、type属性即可。 下图是JSXExpressionContainer的描述信息,内部有expression属性,包括了arguments属性,来描述函数参数,callee表明调用函数 了解了代码的主体AST结构后,来分析program的enter函数了做了什么事情? 其实主要是标记runtime运行时的模式为classic,以及生成React.createElement的AST节点,保存在state中,以便后续使用。下图就是React.createElement的描述信息: // 如何生成React.createElement的AST节点信息, // 感兴趣的可以参照以下代码看看babel官网 function toMemberExpression(id) { return id.split(".").map(name => _core.types.identifier(name)).reduce((object, property) => _core.types.memberExpression(object, property)); } 接下来到了transform转换过程。在JSXElement节点类型exit函数内触发: JSXElement: { exit(path, file) { let callExpr; // ...省略若干代码 // 生成最新节点 callExpr = buildCreateElementCall(path, file); // ...省略若干代码 // 替换原有节点 path.replaceWith(_core.types.inherits(callExpr, path.node)); } } function buildCreateElementCall(path, file) { const openingPath = path.get("openingElement"); // getTag(openingPath)---> returns h1节点 return call(file, "createElement", [getTag(openingPath), buildCreateElementOpeningElementAttributes(file, path, openingPath.get("attributes")), ..._core.types.react.buildChildren(path.node)]); } // _core.types.react.buildChildren 对应下述函数,生成elements数组, 见下图 // 其实就是将之前JSXElement里的JSXText JSXExpressionContainer信息提取出来 // types/lib/builders/react/buildChildren.js function buildChildren(node) { const elements = []; for (let i = 0; i < node.children.length; i++) { let child = node.children[i]; if ((0, _generated.isJSXText)(child)) { (0, _cleanJSXElementLiteralChild.default)(child, elements); continue; } if ((0, _generated.isJSXExpressionContainer)(child)) child = child.expression; if ((0, _generated.isJSXEmptyExpression)(child)) continue; elements.push(child); } return elements; } 最终生成的React.createElement节点信息,这里也增加了leadingComments前置注释信息:/#PURE/,来替换掉原有的节点信息。那么最终在Babel的generate环节,就会生成相应的JS代码:/#PURE/React.createElement("h1", null, "Hello, ", formatName(user), "!"); Babel generate生成代码环节,即针对不同的AST节点信息,采用相应的处理函数。比如通过node.type来获取节点类型program,获取到提前声明好的function program () { ... }处理函数。对于AST抽象语法树这样的数据接口,采取深度优先遍历。使用递归或者循环的方式。如果AST层级过深,那递归层次也会过深,可能栈溢出,所以可以加一个数组(作为栈)来记录接下来要遍历的 AST,这样就可以变成循环了。 以下是Babel generator中的主要处理函数: print(node, parent) { if (!node) return; // 这里获取 每种ast节点相应的处理方式 const printMethod = this[node.type]; // ... 省略代码 // 通过循环的方式,维护一个数组,进行处理 this._printStack.push(node); // ... 省略代码 this.withSource("start", loc, () => { printMethod.call(this, node, parent); }); // ... 省略代码 if (shouldPrintParens) this.token(")"); this._printStack.pop(); } Vue 这部分的解析过程是基于Vue 3.1.0-beta.7版本 与React不同的是,Vue template的解析方式是自己内部实现的,在@packages/compiler-core中,提供了baseCompile、baseParse等核心compiler函数,在baseParse函数中,主要是通过特定字符来循环解析代码字符串,比如< ! / a-z等。 // baseCompile const ast = baseParse(template, options) export function baseParse( content: string, options: ParserOptions = {} ): RootNode { // 生成parse context上下文,主要内容{ options: {delimiters: ['{{', '}}']}, source: 'html string', originalSource: 'html string' } 后面解析html成ast,主要是操作context.source const context = createParserContext(content, options) // 记录初始位置 {line: 0, column:0, offset: 0} const start = getCursor(context) // 主要是parseChildren解析,在这个函数内,判断html string( context.source---> s);返回的数据格式是[node {}, node...] // parseChildren内部声明了nodes = [];每解析完一个标签 字符,就会nodes.push(node) // 1. s[0] 是 < // 1.1 s[1] 是!; 有三种情况:注释<!-- 文档<!DOCTYPE <![CDATA[;分别用parseComment parseBogusComment解析 // 1.2 s[1] 是 /: s[2] > 前进3个字符; s[2] a-z 前进3个字符 // 1.3 s[1] 是a-z 则表明是element标签,parseElement进行解析 // parseElement内会 调用parseTag,利用正则匹配出tag, 再次调用parseChildren 生成子节点,并把当前父节点作为ancestor传给 parseChildren,parseChildren 内部调用isEnd函数判断接下来的s字符串是否是父标签的结束标签。 // 2. s[0]是{{,parseInterpolation,生成动态标签 dynamic // 否则就是文本,调用parseText进行解析 return createRoot( parseChildren(context, TextModes.DATA, []), getSelection(context, start) ) } 在generate阶段,则Babel的设计思路差不多,通过递归的方式生成代码 // 1. 生成generate上下文,包括{code: '', helper() {}, push() {}, indent() {}, newline() {}} // 2. genFunctionPreamble push(`const _Vue = ${VueBinding}\n`) push(`const { ${staticHelpers} } = _Vue\n`) const functionName = ssr ? `ssrRender` : `render` const args = ssr ? ['_ctx', '_push', '_parent', '_attrs'] : ['_ctx', '_cache'] push(`function ${functionName}(${signature}) {`) // ... if (useWithBlock) { push(`with (_ctx) {`) indent() // function mode const declarations should be inside with block // also they should be renamed to avoid collision with user properties if (hasHelpers) { push( `const { ${ast.helpers .map(s => `${helperNameMap[s]}: _${helperNameMap[s]}`) .join(', ')} } = _Vue` ) push(`\n`) newline() } } // ... if (ast.codegenNode) { // genNode根据node.type判断不同类型: // genVNodeCall genText genExpression genInterpolation genComment genNode(ast.codegenNode, context) } else { push(`null`) } 在前端工程化很成熟的今天,我们用的很多工具都是依赖于AST抽象语法树进行解析和构建的。大体的思路都是parse、transform、generate三个阶段。

16
life.caoover 5 years

微信小程序分包实操

微信小程序分包实操 背景: 总包过太,代码首包超了2M。 描述: 将代码划分成不同的包,打开一个包中的某个页面,才加载这个包的代码。也可预先加载其他的包。 为什么需要分包? 一、小程序分包大小限制 未分包情况:代码总包大小不超2M 分包情况:整个小程序所有分包大小不超过 20M 分包情况:单个分包/主包大小不能超过 2M 二、按需加载,可实现访问某些页面去触发加载分包(配置preloadRule) 三、优化下载和启动时间。 四、可有效分割多种业务、多个团队共同开发的情况。 分包注意事项 启动页 & TabBar 配置的路径必须放主包里。 不同的分包间资源不能相用,但可引用主包中的。即:公共相关的基本都放主包,也会导致主包偏大。 多个分包之间尽量互相隔离。 虽然可以 require('../subPackageA/utils.js') 访问到 分包实操 manifest.json 配置处开启分包 // manifest.json 开启分包 "mp-weixin": { "optimization": { "subPackages": true } } 按拆包规则配置路由 注意事项:分包后的文件路径、图片路径,以及路由跳转路径都要做对应修改 (尽量不改变原来路由) // pages.json 路由 { "pages": [{ "path": "pages/index/index", "style": {...} }], "subPackages": [{ "root": "subpage", "pages": [{ "path": "list/list", "style": { ...} }], // "independent": true // 用于区分常规分包和独立分包 一般不这么干 }], "preloadRule": { // 预加载 "pages/car-maintenance/home/index": { "network": "all", "packages": ["subpage"] } } } 常规分包和独立分包的区别: 独立分包可独立于主包和其他分包运行。 独立分包中不能定义 App,会造成无法预期的行为;同时独立分包中暂时不支持使用插件; 拆包思想 这里,可以按项目的业务逻辑、不同页面的使用频率、喜好规划来弄。这边目前是结合现状、线上页面统计访问频率、页面路由层级、以及页面重要程度来进行区分。 低版本兼容 由微信后台编译来处理旧版本客户端的兼容,后台会编译两份代码包,一份是分包后代码,另外一份是整包的兼容代码。 新客户端用分包,老客户端还是用的整包,完整包会把各个 subpackage 里面的路径放到 pages 中。 分包结果 查看路径: 开发者工具 --> 详情 ---> 基本信息 ---> 本地代码查看 分包前: 分包后: 体验上,在跳分包页面路径会加载比较久,所以还是需配置 preloadRule 预加载 。 总结:提前做好拆包规划(无包袱),主次分明、包与包尽量互相隔离 官方链接

319
life.caoover 5 years

app内刘海屏css适配

app内刘海屏css适配 适配之前需要了解的几个点 1.安全区域 安全区域指的是一个可视窗口范围,处于安全区域的内容不受圆角、刘海、底部黑条影响。 适配的宗旨就是保证页面可视、可操作区域是在安全区域内。 2.viewport-fit iOS11 的新增特性,为了适配 iPhoneX +, 对现有 viewport meta 标签的一个扩展,可设置三个值: contain: 可视窗口完全包含网页内容(左图) cover:网页内容完全覆盖可视窗口(右图) auto:默认值,跟 contain 表现一致 适配刘海屏、底部黑条,head标签处须设置meta viewport-fit=cover ,这样对应的预定义css才会生效。 <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover" /> 3.env() 和 constant() Webkit 的一个 CSS 函数,用于设定安全区域与边界的距离,有四个预定义的变量: safe-area-inset-left:安全区域距离左边边界距离 safe-area-inset-right:安全区域距离右边边界距离 safe-area-inset-top:安全区域距离顶部边界距离 safe-area-inset-bottom:安全区域距离底部边界距离 通常竖屏情况:只需关注safe-area-inset-top、safe-area-inset-bottom,左右值为0。 横屏情况: 就需要考虑左边边界、右边边界的值。 横屏安全区域 端内适配建议 通常在端内会使用原生的头部,所以这里需提前关注隐藏自定义头&padding 距离 自定义头 原生头 底部黑条距离,safe-area-inset-bottom .foot{ padding-bottom: calc(constant(safe-area-inset-bottom)); padding-bottom: calc(env(safe-area-inset-bottom)); // 原有基数情况 padding-bottom: 20px; padding-bottom: calc(20px(假设值) + constant(safe-area-inset-bottom)); padding-bottom: calc(20px(假设值) + env(safe-area-inset-bottom)); } Bug: 在端内,如果一开始渲染页面高度没有达到100%,那底部就会出现一个原生的安全区域, 导致出现双层安全区域。 双层区域 // fix: 去除原生的底部安全区域 html { min-height: 100vh; } body { min-height: 100vh; } 很多 Android 手机也会按照 iOS 的标准来实现安全区域,大部分 Android 手机上也能正常使用。

123
life.caoabout 2 hours

前端从VS Code换成了Cursor

前端从VS Code换成了Cursor 家人们谁懂啊,作为一个天天跟Vue3、TS、Element Plus打交道的业务前端,我之前一直觉得VS Code就是编辑器的天花板了——装了几十款插件,开了Copilot,快捷键背得滚瓜烂熟,觉得这辈子都不会换编辑器了。 结果被同组的同事安利了Cursor,抱着“反正底层也是VS Code,试试也不亏”的心态用了两周,现在我的VS Code已经在电脑里吃灰了。 这篇文章就跟大家聊聊,一个普通前端开发,到底怎么把Cursor用出「效率翻倍」的效果,全程都是我日常开发的真实用法,没有官方教程那种假大空的内容,看完就能直接上手。 先跟不了解的同学唠两句:Cursor到底是个啥? 说白了,Cursor就是一款AI原生的代码编辑器,最让我放心的一点是,它底层是基于VS Code构建的——这也是我敢直接换的核心原因:上手零成本。 我之前在VS Code里用的Volar、ESLint、Prettier、主题插件,甚至连自定义的快捷键,全都能一键同步过来,前后10分钟就完成了迁移,完全没有换工具的阵痛期。 它跟「VS Code+Copilot」最大的区别是:Copilot本质上只是个「代码补全插件」,只能在你敲代码的时候给你补两行;而Cursor的AI能力是嵌到编辑器骨子里的,你写代码的全流程,从需求拆解、代码生成、调试重构到上线前检查,它都能无缝接入,而不是只做个“补全工具”。 前端开发最常用的5个场景,全是能直接抄的用法 我不会给你讲一堆花里胡哨、日常根本用不上的功能,就说我每天上班必用的几个场景,每一个都能帮你少加半小时班。 1. 10秒生成业务组件,告别重复CV 前端开发80%的时间,都在写重复的业务组件:带校验的表单、自适应的卡片列表、详情页、二次确认弹窗… 之前我要么从老项目里CV过来改,要么对着组件库文档一行行写,光搭个架子半小时就没了。 现在我只需要选中要写的文件,按 Ctrl/Cmd + K 唤起快速输入框,扔进去需求,直接生成能用的代码。 给大家看我日常用的prompt模板,直接抄就行: 帮我修改这段卡片列表的样式 改成响应式布局:375px 以下一行 2 个,768px 以上一行 4 个,1200px 以上一行 6 个 每个卡片加上 hover 上浮 + 阴影的过渡动画,时长 0.3s,要流畅不卡顿 解决 Safari 浏览器里 flex 布局换行错乱的兼容问题 加上移动端 1px 下边框,解决高清屏线条变粗的问题 几秒钟就给你改好了,连兼容前缀都给你加上了,不用再去搜「Safari flex兼容问题」「移动端1px边框解决方案」了。 4. 接口联调+TS类型自动生成,不用再手动CV 前端联调接口,最烦的就是手动写TS类型——后端给了几十字段的swagger文档,要一个一个抄到interface里,写错一个字母就会出bug,费时又费力。 用Cursor,直接把后端的接口文档、返回示例,甚至swagger地址扔进去,直接让它生成: `根据下面的接口返回数据,生成对应的 TypeScript interface,要求字段名和类型完全对应,每个字段加上中文注释 {"code": 200,"data": {"id": 1,"username": "test","phone": "13800138000","createTime": "2024-01-01 00:00:00","roleList": [{"id": 1, "roleName": "管理员"}]},"msg": "success"}` 甚至你可以让它直接帮你封装好axios请求函数,加上请求/响应拦截、错误处理、loading状态管理,连mock数据都能一起生成,联调效率直接拉满。 5. Debug神器,报错直接给你改好 写代码最崩溃的,就是控制台报了一堆红,搜了半天也找不到问题在哪——比如Vue的响应式丢失、TS类型报错、跨域问题、打包构建失败… 之前我要把报错信息复制到搜索引擎,翻半天帖子才能找到解决方案,现在直接把报错信息+对应的代码,全选扔给Cursor,直接问: 这段代码运行报了这个错,帮我找出问题的根本原因,直接给我修改后的完整代码,顺便告诉我为什么会出现这个问题,下次怎么避免 它不仅会给你改好代码,还会给你讲清楚报错的根本原因,相当于一边写代码一边学东西,下次再遇到同样的问题,自己就会解决了。 前端必记的3个核心快捷键,形成肌肉记忆超爽 Cursor的快捷键有很多,但我日常用的最多的就3个,完全覆盖了90%的开发场景,现在已经形成肌肉记忆了: 快捷键 核心作用 我的高频使用场景 Ctrl/Cmd + K 快速唤起AI输入框,支持选中代码直接修改 写业务组件、改样式、快速生成代码片段 Ctrl/Cmd + L 选中代码打开对话面板,针对这段代码持续对话 梳理代码逻辑、排查bug、重构老代码 Ctrl/Cmd + I 在当前光标位置,直接插入AI生成的代码 补全代码、写注释、加TS类型定义 我踩过的4个坑,大家一定要避开 用了两个多月,我也踩了不少坑,这里给大家提个醒,别跟我一样走弯路: 不要完全依赖AI,核心逻辑一定要自己把控 有一次我让AI帮我写一个复杂的列表无限滚动动画,生成的代码虽然能跑,但是在低端安卓机上疯狂掉帧,线上出了问题还是我自己背锅。AI生成的代码只能当参考,核心的性能、业务逻辑,一定要自己过一遍,逐行检查。 公司敏感代码,一定要开本地模式 如果是写公司的核心业务代码,一定要在设置里打开「本地模式」,关闭云端代码同步,避免代码泄露,这个是底线,千万别嫌麻烦。 免费版够用,重度使用再开Pro 免费版每个月有50次GPT-4调用额度,还有无限的GPT-3.5调用,日常写业务用3.5完全够了,只有遇到复杂的重构、疑难bug排查再用4,个人开发免费版基本够用,不用一上来就开Pro。 生成的代码一定要过一遍ESLint和Prettier AI生成的代码有时候格式会乱,甚至会有不符合你团队规范的写法,一定要用ESLint格式化一遍,不然提交代码的时候,真的会被同事骂。 最后:到底值不值得从VS Code换到Cursor? 很多人说,AI会替代前端开发,但我用了这么久,反而觉得它让我更值钱了。 之前我要花60%的时间,写重复的表单、调样式、改bug、抄类型定义,这些没有技术含量的活,占了我大部分的工作时间。现在这些活,AI10分钟就能帮我干完,我能花更多的时间去研究用户体验、性能优化、前端架构设计,这些才是前端开发的核心竞争力。 对于普通前端开发来说,Cursor不是一个「替代你的工具」,而是一个「帮你提效的搭档」。它的上手成本几乎为零,只要你会用VS Code,就能无缝切换,为什么不试试呢?

0