diff --git a/lib/mp-compiler/index.js b/lib/mp-compiler/index.js index d714eba..345a8b8 100644 --- a/lib/mp-compiler/index.js +++ b/lib/mp-compiler/index.js @@ -4,8 +4,9 @@ const compiler = require('mpvue-template-compiler') const babel = require('babel-core') const path = require('path') const fs = require('fs') +const deepEqual = require('deep-equal') -const { parseConfig, parseComponentsDeps } = require('./parse') +const { parseConfig, parseComponentsDeps, parseGlobalComponents, clearGlobalComponents } = require('./parse') const { parseComponentsDeps: parseComponentsDepsTs } = require('./parse-ts') const { genScript, genStyle, genPageWxml } = require('./templates') @@ -22,22 +23,7 @@ const { getPageSrc } = require('./util') -let emitFileTimer = null - -function createSlotsWxml (emitFile, slots, importCode) { - cacheSlots(slots, importCode) - const content = getSlots() - // 100 delay 比较符合当前策略 - const delay = 100 - if (content.trim()) { - if (emitFileTimer) { - clearTimeout(emitFileTimer) - } - emitFileTimer = setTimeout(function () { - emitFile('components/slots.wxml', htmlBeautify(content)) - }, delay) - } -} +let slotsHookAdded = false // 调用 compiler 生成 wxml function genComponentWxml (compiled, options, emitFile, emitError, emitWarning) { @@ -46,7 +32,7 @@ function genComponentWxml (compiled, options, emitFile, emitError, emitWarning) const { mpErrors, mpTips } = cp // 缓存 slots,延迟编译 - createSlotsWxml(emitFile, slots, importCode) + cacheSlots(slots, importCode) if (mpErrors && mpErrors.length) { emitError( @@ -62,43 +48,66 @@ function genComponentWxml (compiled, options, emitFile, emitError, emitWarning) return htmlBeautify(wxmlCodeStr) } -function createWxml (emitWarning, emitError, emitFile, resourcePath, rootComponent, compiled, html) { - const { pageType, moduleId, components, src } = getFileInfo(resourcePath) || {} - - // 这儿一个黑魔法,和 webpack 约定的规范写法有点偏差! - if (!pageType || (components && !components.isCompleted)) { - return setTimeout(createWxml, 20, ...arguments) - } - - let wxmlContent = '' - let wxmlSrc = '' +function createAppWxml (emitFile, resourcePath, rootComponent) { + const { src } = getFileInfo(resourcePath) || {} + const componentName = getCompNameBySrc(rootComponent) + const wxmlContent = genPageWxml(componentName, src) + const wxmlSrc = src + emitFile(`${wxmlSrc}.wxml`, wxmlContent) +} +// 更新全局组件时,需要重新生成wxml,用这个字段保存所有需要更新的页面及其参数 +const cacheCreateWxmlFns = {} - if (rootComponent) { - const componentName = getCompNameBySrc(rootComponent) - wxmlContent = genPageWxml(componentName, src) - wxmlSrc = src - } else { - // TODO, 这儿传 options 进去 - // { - // components: { - // 'com-a': { src: '../../components/comA$hash', name: 'comA$hash' } - // }, - // pageType: 'component', - // name: 'comA$hash', - // moduleId: 'moduleId' - // } - const name = getCompNameBySrc(resourcePath) - const options = { components, pageType, name, moduleId } - wxmlContent = genComponentWxml(compiled, options, emitFile, emitError, emitWarning) - wxmlSrc = `components/${name}` - } +function createWxml (emitWarning, emitError, emitFile, resourcePath, rootComponent, compiled, html) { + cacheCreateWxmlFns[resourcePath] = arguments + const { pageType, moduleId, components } = getFileInfo(resourcePath) || {} + + // TODO, 这儿传 options 进去 + // { + // components: { + // 'com-a': { src: '../../components/comA$hash', name: 'comA$hash' } + // }, + // pageType: 'component', + // name: 'comA$hash', + // moduleId: 'moduleId' + // } + const name = getCompNameBySrc(resourcePath) + const options = { components, pageType, name, moduleId } + const wxmlContent = genComponentWxml(compiled, options, emitFile, emitError, emitWarning) + const wxmlSrc = `components/${name}` emitFile(`${wxmlSrc}.wxml`, wxmlContent) } // 编译出 wxml function compileWxml (compiled, html) { - return createWxml(this.emitWarning, this.emitError, this.emitFile, this.resourcePath, null, compiled, html) + if (!slotsHookAdded) { + // avoid add hook several times during compilation + slotsHookAdded = true + // TODO: support webpack4 + this._compilation.plugin('seal', () => { + const content = getSlots() + if (content.trim()) { + this.emitFile('components/slots.wxml', htmlBeautify(content)) + } + // reset flag after slots file emited + slotsHookAdded = false + }) + } + return new Promise(resolve => { + const pollComponentsStatus = () => { + const { pageType, components } = getFileInfo(this.resourcePath) || {} + if (!pageType || (components && !components.isCompleted)) { + setTimeout(pollComponentsStatus, 20) + } else { + resolve() + } + } + pollComponentsStatus() + }) + .then(() => { + createWxml(this.emitWarning, this.emitError, this.emitFile, this.resourcePath, null, compiled, html) + }) } // 针对 .vue 单文件的脚本逻辑的处理 @@ -124,33 +133,18 @@ function compileMPScript (script, mpOptioins, moduleId) { // 处理子组件的信息 const components = {} + const fileInfo = resolveTarget(this.resourcePath, this.options.entry) if (originComponents) { - const allP = Object.keys(originComponents).map(k => { - return new Promise((resolve, reject) => { - this.resolve(this.context, originComponents[k], (err, realSrc) => { - if (err) return reject(err) - const com = covertCCVar(k) - const comName = getCompNameBySrc(realSrc) - components[com] = { src: comName, name: comName } - resolve() - }) - }) + resolveSrc(originComponents, components, this.resolve, this.context).then(() => { + resolveComponent(this.resourcePath, fileInfo, importsMap, components, moduleId) + }).catch(err => { + console.error(err) + resolveComponent(this.resourcePath, fileInfo, importsMap, components, moduleId) }) - Promise.all(allP) - .then(res => { - components.isCompleted = true - }) - .catch(err => { - console.error(err) - components.isCompleted = true - }) } else { - components.isCompleted = true + resolveComponent(this.resourcePath, fileInfo, importsMap, components, moduleId) } - const fileInfo = resolveTarget(this.resourcePath, this.options.entry) - cacheFileInfo(this.resourcePath, fileInfo, { importsMap, components, moduleId }) - return script } @@ -158,19 +152,54 @@ function compileMPScript (script, mpOptioins, moduleId) { // 编译出 app, page 的入口js/wxml/json const startPageReg = /^\^/ - +let globalComponents function compileMP (content, mpOptioins) { - const { resourcePath, emitError, emitFile, emitWarning, resolve, context, options } = this - - const babelrc = getBabelrc(mpOptioins.globalBabelrc) - const { metadata } = babel.transform(content, { extends: babelrc, plugins: [parseConfig] }) - - // metadata: config - const { config, rootComponent } = metadata + const { resourcePath, emitFile, resolve, context, options } = this const fileInfo = resolveTarget(resourcePath, options.entry) cacheFileInfo(resourcePath, fileInfo) const { src, name, isApp, isPage } = fileInfo + if (isApp) { + // 解析前将可能存在的全局组件清空 + clearGlobalComponents() + } + + const babelrc = getBabelrc(mpOptioins.globalBabelrc) + // app入口进行全局component解析 + const { metadata } = babel.transform(content, { extends: babelrc, plugins: isApp ? [parseConfig, parseGlobalComponents] : [parseConfig] }) + + // metadata: config + const { config, rootComponent, globalComponents: globalComps } = metadata + + if (isApp) { + // 保存旧数据,用于对比 + const oldGlobalComponents = globalComponents + // 开始解析组件路径时把全局组件清空,解析完成后再进行赋值,标志全局组件解析完成 + globalComponents = null + + // 解析全局组件的路径 + const components = {} + resolveSrc(globalComps, components, resolve, context).then(() => { + handleResult(components) + }).catch(err => { + console.error(err) + handleResult(components) + }) + const handleResult = components => { + globalComponents = components + // 热更时,如果全局组件更新,需要重新生成所有的wxml + if (oldGlobalComponents && !deepEqual(oldGlobalComponents, globalComponents)) { + // 更新所有页面的组件 + Object.keys(cacheResolveComponents).forEach(k => { + resolveComponent(...cacheResolveComponents[k]) + }) + // 重新生成所有wxml + Object.keys(cacheCreateWxmlFns).forEach(k => { + createWxml(...cacheCreateWxmlFns[k]) + }) + } + } + } if (isApp || isPage) { // 生成入口 json @@ -205,7 +234,7 @@ function compileMP (content, mpOptioins) { resolve(context, rootComponent, (err, rootComponentSrc) => { if (err) return // 这儿需要搞定 根组件的 路径 - createWxml(emitWarning, emitError, emitFile, resourcePath, rootComponentSrc) + createAppWxml(emitFile, resourcePath, rootComponentSrc) }) } } @@ -213,4 +242,32 @@ function compileMP (content, mpOptioins) { return content } +function resolveSrc (originComponents, components, resolveFn, context) { + return Promise.all(Object.keys(originComponents).map(k => { + return new Promise((resolve, reject) => { + resolveFn(context, originComponents[k], (err, realSrc) => { + if (err) return reject(err) + const com = covertCCVar(k) + const comName = getCompNameBySrc(realSrc) + components[com] = { src: comName, name: comName } + resolve() + }) + }) + })) +} + +const cacheResolveComponents = {} +function resolveComponent (resourcePath, fileInfo, importsMap, localComponents, moduleId) { + // 需要等待全局组件解析完成 + if (!globalComponents) { + setTimeout(resolveComponent, 20, ...arguments) + } else { + // 保存当前所有参数,在热更时如果全局组件发生变化,需要进行组件更新 + cacheResolveComponents[resourcePath] = arguments + const components = Object.assign({}, globalComponents, localComponents) + components.isCompleted = true + cacheFileInfo(resourcePath, fileInfo, { importsMap, components, moduleId }) + } +} + module.exports = { compileWxml, compileMPScript, compileMP } diff --git a/lib/mp-compiler/parse-ts.js b/lib/mp-compiler/parse-ts.js index 5792a1f..44acaf4 100644 --- a/lib/mp-compiler/parse-ts.js +++ b/lib/mp-compiler/parse-ts.js @@ -2,10 +2,13 @@ let ts try { ts = require('typescript') } catch (e) { - + // console.error(e) } function parseComponentsDeps (scriptContent) { + if (ts === null) { + throw new Error('Please run `npm install -S typescript` to install TypeScript.') + } const sourceFile = ts.createSourceFile('test', scriptContent, ts.ScriptTarget.ESNext, /* setParentNodes */ true) return delint(sourceFile) } @@ -21,7 +24,10 @@ function delint (sourceFile) { if (node.expression.expression && node.expression.expression.escapedText === 'Component') { const compArgs = node.expression.arguments if (compArgs && compArgs.length === 1) { - const vueClassArg = compArgs[0] + let vueClassArg = compArgs[0] + if (vueClassArg.kind === ts.SyntaxKind.AsExpression) { // @Component({ components: ...,} as any) + vueClassArg = vueClassArg.expression + } if (vueClassArg.properties) { vueClassArg.properties.forEach((classProp) => { // 处理components属性 diff --git a/lib/mp-compiler/parse.js b/lib/mp-compiler/parse.js index 5301542..c15cd7e 100644 --- a/lib/mp-compiler/parse.js +++ b/lib/mp-compiler/parse.js @@ -55,6 +55,11 @@ const configVisitor = { } const arg = path.node.arguments[0] + + if (!arg) { + return + } + const v = arg.type === 'Identifier' ? importsMap[arg.name] : importsMap['App'] metadata.rootComponent = v || importsMap['index'] || importsMap['main'] } @@ -98,4 +103,34 @@ function parseComponentsDeps (babel) { return { visitor: componentsVisitor } } -module.exports = { parseConfig, parseComponentsDeps } +// 解析全局components +let globalComponents = {} +const globalComponentsVisitor = { + CallExpression (path) { + const { callee, arguments: args } = path.node + const { metadata } = path.hub.file + if (!callee.object || !callee.property) { + return + } + if (callee.object.name === 'Vue' && callee.property.name === 'component') { + if (!args[0] || args[0].type !== 'StringLiteral') { + throw new Error('Vue.component()的第一个参数必须为静态字符串') + } + if (!args[1]) { + throw new Error('Vue.component()需要两个参数') + } + const { importsMap } = getImportsMap(metadata) + globalComponents[args[0].value] = importsMap[args[1].name] + } + metadata.globalComponents = globalComponents + } +} + +function parseGlobalComponents (babel) { + return { visitor: globalComponentsVisitor } +} + +function clearGlobalComponents () { + globalComponents = {} +} +module.exports = { parseConfig, parseComponentsDeps, parseGlobalComponents, clearGlobalComponents } diff --git a/lib/template-compiler/index.js b/lib/template-compiler/index.js index 9dfacd8..b2d23e9 100644 --- a/lib/template-compiler/index.js +++ b/lib/template-compiler/index.js @@ -10,13 +10,18 @@ var transformRequire = require('./modules/transform-require') var compileWxml = require('../mp-compiler').compileWxml module.exports = function (html) { + this.async() this.cacheable() var isServer = this.target === 'node' var isProduction = this.minimize || process.env.NODE_ENV === 'production' var vueOptions = this.options.__vueOptions__ || {} var options = loaderUtils.getOptions(this) || {} - var defaultModules = [transformRequire(options.transformToRequire)] + var defaultModules = [transformRequire(options.transformToRequire, { + outputPath: this.options.output.path, + resourcePath: this.resourcePath + })] + var userModules = vueOptions.compilerModules || options.compilerModules // for HappyPack cross-process use cases if (typeof userModules === 'string') { @@ -38,56 +43,57 @@ module.exports = function (html) { // for mp => *.wxml compileWxml.call(this, compiled, html) + .then(() => { + // tips + if (compiled.tips && compiled.tips.length) { + compiled.tips.forEach(tip => { + this.emitWarning(tip) + }) + } - // tips - if (compiled.tips && compiled.tips.length) { - compiled.tips.forEach(tip => { - this.emitWarning(tip) - }) - } + var code + if (compiled.errors && compiled.errors.length) { + this.emitError( + `\n Error compiling template:\n${pad(html)}\n` + + compiled.errors.map(e => ` - ${e}`).join('\n') + '\n' + ) + code = vueOptions.esModule + ? `var esExports = {render:function(){},staticRenderFns: []}\nexport default esExports` + : 'module.exports={render:function(){},staticRenderFns:[]}' + } else { + var bubleOptions = options.buble + code = transpile( + 'var render = ' + toFunction(compiled.render) + '\n' + + 'var staticRenderFns = [' + compiled.staticRenderFns.map(toFunction).join(',') + ']', + bubleOptions + ) + '\n' + // mark with stripped (this enables Vue to use correct runtime proxy detection) + if (!isProduction && ( + !bubleOptions || + !bubleOptions.transforms || + bubleOptions.transforms.stripWith !== false + )) { + code += `render._withStripped = true\n` + } + var exports = `{ render: render, staticRenderFns: staticRenderFns }` + code += vueOptions.esModule + ? `var esExports = ${exports}\nexport default esExports` + : `module.exports = ${exports}` + } + // hot-reload + if (!isServer && !isProduction) { + var exportsName = vueOptions.esModule ? 'esExports' : 'module.exports' + code += + '\nif (module.hot) {\n' + + ' module.hot.accept()\n' + + ' if (module.hot.data) {\n' + + ' require("' + hotReloadAPIPath + '").rerender("' + options.id + '", ' + exportsName + ')\n' + + ' }\n' + + '}' + } - var code - if (compiled.errors && compiled.errors.length) { - this.emitError( - `\n Error compiling template:\n${pad(html)}\n` + - compiled.errors.map(e => ` - ${e}`).join('\n') + '\n' - ) - code = vueOptions.esModule - ? `var esExports = {render:function(){},staticRenderFns: []}\nexport default esExports` - : 'module.exports={render:function(){},staticRenderFns:[]}' - } else { - var bubleOptions = options.buble - code = transpile( - 'var render = ' + toFunction(compiled.render) + '\n' + - 'var staticRenderFns = [' + compiled.staticRenderFns.map(toFunction).join(',') + ']', - bubleOptions - ) + '\n' - // mark with stripped (this enables Vue to use correct runtime proxy detection) - if (!isProduction && ( - !bubleOptions || - !bubleOptions.transforms || - bubleOptions.transforms.stripWith !== false - )) { - code += `render._withStripped = true\n` - } - var exports = `{ render: render, staticRenderFns: staticRenderFns }` - code += vueOptions.esModule - ? `var esExports = ${exports}\nexport default esExports` - : `module.exports = ${exports}` - } - // hot-reload - if (!isServer && !isProduction) { - var exportsName = vueOptions.esModule ? 'esExports' : 'module.exports' - code += - '\nif (module.hot) {\n' + - ' module.hot.accept()\n' + - ' if (module.hot.data) {\n' + - ' require("' + hotReloadAPIPath + '").rerender("' + options.id + '", ' + exportsName + ')\n' + - ' }\n' + - '}' - } - - return code + this.callback(null, code) + }) } function toFunction (code) { diff --git a/lib/template-compiler/modules/transform-require.js b/lib/template-compiler/modules/transform-require.js index 7d9be15..b144265 100644 --- a/lib/template-compiler/modules/transform-require.js +++ b/lib/template-compiler/modules/transform-require.js @@ -1,50 +1,59 @@ // vue compiler module for transforming `:` to `require` +var fs = require('fs') +var path = require('path') +var mkdirp = require('mkdirp') + var defaultOptions = { img: 'src', image: 'xlink:href' } -module.exports = userOptions => { +module.exports = (userOptions, fileOptions) => { var options = userOptions ? Object.assign({}, defaultOptions, userOptions) : defaultOptions return { postTransformNode: node => { - transform(node, options) + transform(node, options, fileOptions) } } } -function transform (node, options) { +function transform (node, options, fileOptions) { for (var tag in options) { if (node.tag === tag && node.attrs) { var attributes = options[tag] if (typeof attributes === 'string') { - node.attrs.some(attr => rewrite(attr, attributes)) + rewrite(node.attrsMap, attributes, fileOptions) } else if (Array.isArray(attributes)) { - attributes.forEach(item => node.attrs.some(attr => rewrite(attr, item))) + attributes.forEach(item => rewrite(node.attrsMap, item, fileOptions)) } } } } -function rewrite (attr, name) { - if (attr.name === name) { - var value = attr.value - var isStatic = value.charAt(0) === '"' && value.charAt(value.length - 1) === '"' - if (!isStatic) { - return - } - var firstChar = value.charAt(1) - if (firstChar === '.' || firstChar === '~') { - if (firstChar === '~') { - var secondChar = value.charAt(2) - value = '"' + value.slice(secondChar === '/' ? 3 : 2) - } - attr.value = `require(${value})` +function rewrite (attrsMap, name, fileOptions) { + var value = attrsMap[name] + if (value) { + var firstChar = value.charAt(0) + if (firstChar === '.') { + // 资源路径 + var assetPath = path.resolve(path.dirname(fileOptions.resourcePath), value) + // 重写路径,为了避免重名,在webpack输出目录下新建copy-asset目录,资源保存到这里 + var assetOutputPath = path.join('copy-asset', path.relative(process.cwd(), assetPath).replace(/^src/, '')) + attrsMap[name] = `/${assetOutputPath.split(path.sep).join('/')}` + copyAsset(assetPath, path.resolve(fileOptions.outputPath, assetOutputPath)) } - return true } } + +function copyAsset (from, to) { + var readStream = fs.createReadStream(from) + mkdirp(path.dirname(to), err => { + if (err) console.error(err) + var writeStream = fs.createWriteStream(to) + readStream.pipe(writeStream) + }) +} diff --git a/package.json b/package.json index af1fc8d..773a5e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mpvue-loader", - "version": "1.0.13", + "version": "1.0.14", "description": "mpvue single-file component loader for Webpack", "main": "index.js", "repository": { @@ -51,10 +51,12 @@ "dependencies": { "babelon": "^1.0.5", "consolidate": "^0.14.0", + "deep-equal": "^1.0.1", "hash-sum": "^1.0.2", "js-beautify": "^1.6.14", "loader-utils": "^1.1.0", "lru-cache": "^4.1.1", + "mkdirp": "^0.5.1", "postcss": "^6.0.6", "postcss-load-config": "^1.1.0", "postcss-selector-parser": "^2.0.0", @@ -67,7 +69,7 @@ }, "peerDependencies": { "css-loader": "*", - "mpvue-template-compiler": "^1.0.10" + "mpvue-template-compiler": "^1.0.12" }, "devDependencies": { "babel-core": "^6.25.0", @@ -89,9 +91,8 @@ "lint-staged": "^4.0.2", "marked": "^0.3.6", "memory-fs": "^0.4.1", - "mkdirp": "^0.5.1", - "mpvue-template-compiler": "^1.0.10", "mocha": "^3.4.2", + "mpvue-template-compiler": "^1.0.12", "node-libs-browser": "^2.0.0", "normalize-newline": "^3.0.0", "null-loader": "^0.1.1",